Skip to main content

pipedash_core/infrastructure/run_logs/
outline.rs

1use std::fs::{
2    self,
3    File,
4};
5use std::io::{
6    self,
7    BufReader,
8    BufWriter,
9    Read,
10    Seek,
11    SeekFrom,
12    Write,
13};
14use std::path::{
15    Path,
16    PathBuf,
17};
18
19use serde::{
20    Deserialize,
21    Serialize,
22};
23
24use super::checkpoint::copy_prefix;
25use super::{
26    MARKER_LINES_CAP,
27    MAX_SECTIONS,
28};
29use crate::domain::{
30    LineKind,
31    LogSection,
32    LogSectionKind,
33    PipelineStatus,
34};
35
36const ERRORS_FILE: &str = "errors.bin";
37const ERRORS_BUFFER_BYTES: usize = 64 * 1024;
38const MAX_TITLE_BYTES: usize = 512;
39const MAX_STEP_MARKERS: usize = 100_000;
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct StepBound {
43    pub node_id: String,
44    pub title: String,
45    pub status: PipelineStatus,
46    pub started_ms: Option<i64>,
47    pub finished_ms: Option<i64>,
48}
49
50#[derive(Serialize, Deserialize)]
51struct Group {
52    header: u32,
53    title: String,
54    end: Option<u32>,
55    first_ms: Option<i64>,
56    last_ms: Option<i64>,
57    open: bool,
58}
59
60pub(crate) struct OutlineOutput {
61    pub sections: Vec<LogSection>,
62    pub error_lines: Vec<u32>,
63    pub warning_lines: Vec<u32>,
64    pub error_total: u32,
65    pub warning_total: u32,
66    pub focus_line: Option<u32>,
67}
68
69#[derive(Default, Serialize, Deserialize)]
70pub(crate) struct Outline {
71    steps: Vec<StepBound>,
72    ts_boundary: Vec<u32>,
73    markers: Vec<(u32, i64)>,
74    groups: Vec<Group>,
75    open_groups: Vec<usize>,
76    ignored_depth: u32,
77    warned: bool,
78    last_ts: Option<i64>,
79    #[serde(skip)]
80    errors_path: PathBuf,
81    #[serde(skip)]
82    errors: Option<BufWriter<File>>,
83    error_total: u32,
84    warning_total: u32,
85    error_lines: Vec<u32>,
86    warning_lines: Vec<u32>,
87}
88
89fn floor_sec(ms: i64) -> i64 {
90    ms.div_euclid(1000)
91}
92
93fn truncate_title(text: &str) -> String {
94    if text.len() <= MAX_TITLE_BYTES {
95        return text.to_string();
96    }
97    let mut end = MAX_TITLE_BYTES;
98    while !text.is_char_boundary(end) {
99        end -= 1;
100    }
101    text[..end].to_string()
102}
103
104fn normalized(mut steps: Vec<StepBound>) -> Vec<StepBound> {
105    steps.sort_by_key(|s| (s.started_ms.is_none(), s.started_ms));
106    steps.truncate(MAX_SECTIONS);
107    steps
108}
109
110impl Outline {
111    pub(crate) fn new(steps: Vec<StepBound>, dir: &Path) -> Self {
112        Self {
113            steps: normalized(steps),
114            errors_path: dir.join(ERRORS_FILE),
115            ..Self::default()
116        }
117    }
118
119    pub(crate) fn accepts(&self, steps: &[StepBound]) -> bool {
120        let steps = normalized(steps.to_vec());
121        let placed = self.ts_boundary.len();
122        steps.len() >= placed
123            && self
124                .steps
125                .iter()
126                .zip(&steps)
127                .take(placed)
128                .all(|(old, new)| old.node_id == new.node_id && old.started_ms == new.started_ms)
129    }
130
131    fn spool_bytes(&self) -> u64 {
132        u64::from(self.error_total) * 4
133    }
134
135    pub(crate) fn spool_fits(&self, dir: &Path) -> bool {
136        self.error_total == 0
137            || fs::metadata(dir.join(ERRORS_FILE)).is_ok_and(|m| m.len() >= self.spool_bytes())
138    }
139
140    pub(crate) fn resume(
141        mut self, steps: Vec<StepBound>, base: &Path, dir: &Path,
142    ) -> io::Result<Self> {
143        self.steps = normalized(steps);
144        self.errors_path = dir.join(ERRORS_FILE);
145        if self.error_total > 0 {
146            let spool = copy_prefix(
147                &base.join(ERRORS_FILE),
148                &self.errors_path,
149                self.spool_bytes(),
150            )?;
151            self.errors = Some(BufWriter::with_capacity(ERRORS_BUFFER_BYTES, spool));
152        }
153        Ok(self)
154    }
155
156    pub(crate) fn discard_spool(&mut self) -> io::Result<()> {
157        self.errors = None;
158        match fs::remove_file(&self.errors_path) {
159            Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
160            _ => Ok(()),
161        }
162    }
163
164    pub(crate) fn line(
165        &mut self, index: u32, kind: LineKind, ts: Option<i64>, text: &str, step_start: bool,
166    ) -> io::Result<()> {
167        if ts.is_some() {
168            self.last_ts = ts;
169        }
170        if let Some(effective) = self.last_ts {
171            let sec = floor_sec(effective);
172            self.advance(index, sec);
173            if step_start && self.markers.len() < MAX_STEP_MARKERS {
174                self.markers.push((index, sec));
175            }
176        }
177        match kind {
178            LineKind::Error => {
179                if self.error_lines.len() < MARKER_LINES_CAP {
180                    self.error_lines.push(index);
181                }
182                self.error_total = self.error_total.saturating_add(1);
183                if self.errors.is_none() {
184                    let file = File::create(&self.errors_path)?;
185                    self.errors = Some(BufWriter::with_capacity(ERRORS_BUFFER_BYTES, file));
186                }
187                if let Some(writer) = self.errors.as_mut() {
188                    writer.write_all(&index.to_le_bytes())?;
189                }
190            }
191            LineKind::Warning => {
192                if self.warning_lines.len() < MARKER_LINES_CAP {
193                    self.warning_lines.push(index);
194                }
195                self.warning_total = self.warning_total.saturating_add(1);
196            }
197            LineKind::Group => self.group_start(index, text),
198            _ => {}
199        }
200        Ok(())
201    }
202
203    fn advance(&mut self, index: u32, sec: i64) {
204        while self.ts_boundary.len() < self.steps.len() {
205            let Some(started) = self.steps[self.ts_boundary.len()].started_ms else {
206                break;
207            };
208            if sec < floor_sec(started) {
209                break;
210            }
211            self.ts_boundary.push(index);
212        }
213    }
214
215    fn group_start(&mut self, index: u32, text: &str) {
216        if self.steps.len() + self.groups.len() < MAX_SECTIONS {
217            self.groups.push(Group {
218                header: index,
219                title: truncate_title(text),
220                end: None,
221                first_ms: self.last_ts,
222                last_ms: None,
223                open: false,
224            });
225            self.open_groups.push(self.groups.len() - 1);
226        } else {
227            if !self.warned {
228                self.warned = true;
229                tracing::warn!("Run log has more than {MAX_SECTIONS} sections; ignoring groups");
230            }
231            self.ignored_depth += 1;
232        }
233    }
234
235    pub(crate) fn group_end(&mut self, line_count: u32, ts: Option<i64>) {
236        if ts.is_some() {
237            self.last_ts = ts;
238        }
239        if self.ignored_depth > 0 {
240            self.ignored_depth -= 1;
241            return;
242        }
243        if let Some(group) = self.open_groups.pop() {
244            let group = &mut self.groups[group];
245            group.end = Some(line_count);
246            group.last_ms = self.last_ts;
247        }
248    }
249
250    pub(crate) fn end_groups(&mut self, line_count: u32) {
251        self.ignored_depth = 0;
252        for group in self.open_groups.drain(..) {
253            let group = &mut self.groups[group];
254            group.end = Some(line_count);
255            group.last_ms = self.last_ts;
256        }
257    }
258
259    pub(crate) fn open_current_group(&mut self, title: &str) {
260        let Some(&group) = self.open_groups.last() else {
261            return;
262        };
263        let group = &mut self.groups[group];
264        group.open = true;
265        if !title.trim().is_empty() {
266            group.title = truncate_title(title);
267        }
268    }
269
270    pub(crate) fn finish(&mut self, line_count: u32) -> io::Result<OutlineOutput> {
271        if let Some(writer) = self.errors.as_mut() {
272            writer.flush()?;
273        }
274
275        let mut drafts = self.step_drafts(line_count);
276        let step_count = drafts.len();
277        let ranges: Vec<(u32, u32)> = drafts.iter().map(|d| (d.start, d.end)).collect();
278        let mut stack: Vec<(usize, Option<usize>)> = Vec::new();
279        for group in &self.groups {
280            let step = containing_step(&ranges, group.header);
281            while let Some(&(top, top_step)) = stack.last() {
282                if drafts[top].end <= group.header || top_step != step {
283                    stack.pop();
284                } else {
285                    break;
286                }
287            }
288            let parent = stack.last().map(|&(top, _)| top).or(step);
289            let mut end = group.end.unwrap_or(line_count);
290            if let Some(parent) = parent {
291                end = end.min(drafts[parent].end);
292            }
293            let last_ms = if group.end.is_none() {
294                self.last_ts
295            } else {
296                group.last_ms
297            };
298            let duration_ms = match (group.first_ms, last_ms) {
299                (Some(first), Some(last)) => Some((last - first).max(0)),
300                _ => None,
301            };
302            drafts.push(Draft {
303                kind: LogSectionKind::Group,
304                node_id: None,
305                title: group.title.clone(),
306                status: None,
307                duration_ms,
308                header: Some(group.header),
309                start: group.header + 1,
310                end,
311                parent,
312                depth: parent.map_or(0, |p| drafts[p].depth.saturating_add(1)),
313                open: group.open,
314            });
315            stack.push((drafts.len() - 1, step));
316        }
317
318        let mut edges: Vec<u32> = drafts
319            .iter()
320            .flat_map(|d| [d.edge_start(), d.end])
321            .collect();
322        edges.sort_unstable();
323        edges.dedup();
324        let prefix = self.prefix_counts(&edges)?;
325        let prefix_at = |edge: u32| {
326            edges
327                .binary_search(&edge)
328                .ok()
329                .and_then(|i| prefix.get(i).copied())
330                .unwrap_or(0)
331        };
332
333        let focus_line = {
334            let failed = drafts[..step_count]
335                .iter()
336                .find(|d| d.status == Some(PipelineStatus::Failed));
337            let in_failed = failed.and_then(|d| {
338                let (from, to) = (prefix_at(d.edge_start()), prefix_at(d.end));
339                (from < to).then_some(from)
340            });
341            match in_failed {
342                Some(nth) => self.nth_error(nth)?,
343                None => self
344                    .error_lines
345                    .first()
346                    .copied()
347                    .or_else(|| failed.and_then(|d| (d.start < d.end).then(|| d.end - 1))),
348            }
349        };
350
351        let only_step = step_count == 1;
352        let mut order: Vec<usize> = (0..drafts.len()).collect();
353        order.sort_by_key(|&i| (drafts[i].edge_start(), drafts[i].depth));
354        let mut new_id = vec![0u32; drafts.len()];
355        for (position, &old) in order.iter().enumerate() {
356            new_id[old] = position as u32;
357        }
358        let sections = order
359            .iter()
360            .map(|&old| {
361                let draft = &drafts[old];
362                let error_count = prefix_at(draft.end) - prefix_at(draft.edge_start());
363                let open = match draft.kind {
364                    LogSectionKind::Step => {
365                        draft.status == Some(PipelineStatus::Failed) || only_step
366                    }
367                    LogSectionKind::Group => error_count > 0 || draft.open,
368                };
369                LogSection {
370                    id: new_id[old],
371                    parent: draft.parent.map(|p| new_id[p]),
372                    kind: draft.kind,
373                    node_id: draft.node_id.clone(),
374                    title: draft.title.clone(),
375                    status: draft.status.clone(),
376                    duration_ms: draft.duration_ms,
377                    header_line: draft.header,
378                    start_line: draft.start,
379                    end_line: draft.end,
380                    error_count,
381                    open,
382                }
383            })
384            .collect();
385
386        Ok(OutlineOutput {
387            sections,
388            error_lines: self.error_lines.clone(),
389            warning_lines: self.warning_lines.clone(),
390            error_total: self.error_total,
391            warning_total: self.warning_total,
392            focus_line,
393        })
394    }
395
396    fn step_drafts(&self, line_count: u32) -> Vec<Draft> {
397        let count = self.steps.len();
398        let mut placed: Vec<Option<u32>> = vec![None; count];
399        let mut previous: Option<u32> = None;
400        let mut cursor = 0;
401        for (k, step) in self.steps.iter().enumerate() {
402            let Some(started) = step.started_ms else {
403                continue;
404            };
405            if step.status == PipelineStatus::Skipped {
406                continue;
407            }
408            if k == 0 {
409                placed[0] = Some(0);
410                previous = Some(0);
411                continue;
412            }
413            let start_sec = floor_sec(started);
414            let end_sec = step.finished_ms.map(floor_sec);
415            let mut found = None;
416            while let Some(&(line, sec)) = self.markers.get(cursor) {
417                if previous.is_some_and(|p| line <= p) || sec < start_sec {
418                    cursor += 1;
419                    continue;
420                }
421                if end_sec.is_some_and(|end| sec > end) {
422                    break;
423                }
424                found = Some(line);
425                cursor += 1;
426                break;
427            }
428            let boundary = found.or_else(|| {
429                let fallback = self.ts_boundary.get(k).copied().unwrap_or(line_count);
430                previous.is_none_or(|p| fallback > p).then_some(fallback)
431            });
432            if let Some(boundary) = boundary {
433                placed[k] = Some(boundary);
434                previous = Some(boundary);
435            }
436        }
437        let mut next = line_count;
438        let mut starts = vec![0u32; count];
439        for k in (0..count).rev() {
440            starts[k] = placed[k].unwrap_or(next).min(line_count);
441            next = starts[k];
442        }
443        self.steps
444            .iter()
445            .enumerate()
446            .map(|(i, step)| {
447                let duration_ms = match (step.started_ms, step.finished_ms) {
448                    (Some(started), Some(finished)) => Some((finished - started).max(0)),
449                    _ => None,
450                };
451                Draft {
452                    kind: LogSectionKind::Step,
453                    node_id: Some(step.node_id.clone()),
454                    title: step.title.clone(),
455                    status: Some(step.status.clone()),
456                    duration_ms,
457                    header: None,
458                    start: starts[i],
459                    end: starts.get(i + 1).copied().unwrap_or(line_count),
460                    parent: None,
461                    depth: 0,
462                    open: false,
463                }
464            })
465            .collect()
466    }
467
468    fn prefix_counts(&self, edges: &[u32]) -> io::Result<Vec<u32>> {
469        let mut out = vec![0u32; edges.len()];
470        let mut next_edge = 0;
471        let mut count = 0u32;
472        if self.error_total > 0 {
473            let mut reader =
474                BufReader::with_capacity(ERRORS_BUFFER_BYTES, File::open(&self.errors_path)?);
475            let mut record = [0u8; 4];
476            loop {
477                match reader.read_exact(&mut record) {
478                    Ok(()) => {}
479                    Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
480                    Err(e) => return Err(e),
481                }
482                let line = u32::from_le_bytes(record);
483                while next_edge < edges.len() && edges[next_edge] <= line {
484                    out[next_edge] = count;
485                    next_edge += 1;
486                }
487                count += 1;
488            }
489        }
490        for slot in &mut out[next_edge..] {
491            *slot = count;
492        }
493        Ok(out)
494    }
495
496    fn nth_error(&self, nth: u32) -> io::Result<Option<u32>> {
497        if let Some(&line) = self.error_lines.get(nth as usize) {
498            return Ok(Some(line));
499        }
500        let mut file = File::open(&self.errors_path)?;
501        file.seek(SeekFrom::Start(u64::from(nth) * 4))?;
502        let mut record = [0u8; 4];
503        file.read_exact(&mut record)?;
504        Ok(Some(u32::from_le_bytes(record)))
505    }
506}
507
508struct Draft {
509    kind: LogSectionKind,
510    node_id: Option<String>,
511    title: String,
512    status: Option<PipelineStatus>,
513    duration_ms: Option<i64>,
514    header: Option<u32>,
515    start: u32,
516    end: u32,
517    parent: Option<usize>,
518    depth: u8,
519    open: bool,
520}
521
522impl Draft {
523    fn edge_start(&self) -> u32 {
524        self.header.unwrap_or(self.start)
525    }
526}
527
528fn containing_step(ranges: &[(u32, u32)], header: u32) -> Option<usize> {
529    ranges
530        .partition_point(|&(start, _)| start <= header)
531        .checked_sub(1)
532        .filter(|&i| header < ranges[i].1)
533}
534
535#[cfg(test)]
536mod tests {
537    use chrono::{
538        DateTime,
539        SecondsFormat,
540    };
541    use pipedash_plugin_actions::ActionsLogDialect;
542
543    use super::*;
544    use crate::domain::{
545        LineMarker,
546        LogDialect,
547    };
548
549    const BASE: i64 = 1_700_000_000_000;
550
551    fn t(sec: i64, ms: i64) -> Option<i64> {
552        Some(BASE + sec * 1000 + ms)
553    }
554
555    fn step(id: &str, status: PipelineStatus, sec: i64) -> StepBound {
556        StepBound {
557            node_id: id.into(),
558            title: id.into(),
559            status,
560            started_ms: t(sec, 0),
561            finished_ms: t(sec + 1, 0),
562        }
563    }
564
565    struct Feed {
566        outline: Outline,
567        lines: u32,
568        _dir: tempfile::TempDir,
569    }
570
571    impl Feed {
572        fn new(steps: Vec<StepBound>) -> Self {
573            let dir = tempfile::tempdir().expect("tempdir");
574            Self {
575                outline: Outline::new(steps, dir.path()),
576                lines: 0,
577                _dir: dir,
578            }
579        }
580
581        fn line(&mut self, ts: Option<i64>, kind: LineKind, text: &str) {
582            self.outline
583                .line(self.lines, kind, ts, text, false)
584                .expect("line");
585            self.lines += 1;
586        }
587
588        fn raw(&mut self, line: &str) {
589            let parsed = ActionsLogDialect.parse(line.as_bytes());
590            let ts = parsed.timestamp.map(|t| t.timestamp_millis());
591            if parsed.marker == LineMarker::GroupEnd {
592                self.outline.group_end(self.lines, ts);
593                return;
594            }
595            let kind = match parsed.marker {
596                LineMarker::GroupStart => LineKind::Group,
597                LineMarker::Error => LineKind::Error,
598                _ => LineKind::Plain,
599            };
600            let text = String::from_utf8_lossy(parsed.body).into_owned();
601            let step_start = ActionsLogDialect.is_step_start(&parsed);
602            self.outline
603                .line(self.lines, kind, ts, &text, step_start)
604                .expect("line");
605            self.lines += 1;
606        }
607
608        fn plain(&mut self, ts: Option<i64>) {
609            self.line(ts, LineKind::Plain, "x");
610        }
611
612        fn group(&mut self, ts: Option<i64>, title: &str) {
613            self.line(ts, LineKind::Group, title);
614        }
615
616        fn error(&mut self, ts: Option<i64>) {
617            self.line(ts, LineKind::Error, "boom");
618        }
619
620        fn end(&mut self, ts: Option<i64>) {
621            self.outline.group_end(self.lines, ts);
622        }
623
624        fn finish(mut self) -> OutlineOutput {
625            self.outline.finish(self.lines).expect("finish")
626        }
627    }
628
629    fn find<'a>(out: &'a OutlineOutput, title: &str) -> &'a LogSection {
630        out.sections
631            .iter()
632            .find(|s| s.title == title)
633            .expect("section")
634    }
635
636    fn bound(id: &str, status: PipelineStatus, start: (i64, i64), end: (i64, i64)) -> StepBound {
637        StepBound {
638            node_id: id.into(),
639            title: id.into(),
640            status,
641            started_ms: t(start.0, start.1),
642            finished_ms: t(end.0, end.1),
643        }
644    }
645
646    fn stamp(sec: i64, ms: i64) -> String {
647        DateTime::from_timestamp_millis(BASE + sec * 1000 + ms)
648            .expect("timestamp")
649            .to_rfc3339_opts(SecondsFormat::Millis, true)
650    }
651
652    fn raw(feed: &mut Feed, sec: i64, ms: i64, body: &str) {
653        feed.raw(&format!("{} {body}", stamp(sec, ms)));
654    }
655
656    #[test]
657    fn run_logs_outline_actions_markers_place_steps_in_shared_seconds() {
658        let mut feed = Feed::new(vec![
659            bound("Set up job", PipelineStatus::Success, (10, 0), (10, 0)),
660            bound("Bulk output", PipelineStatus::Success, (10, 0), (37, 0)),
661            bound("Fail", PipelineStatus::Failed, (37, 0), (37, 0)),
662            bound("Complete job", PipelineStatus::Success, (37, 0), (37, 0)),
663        ]);
664        raw(&mut feed, 10, 0, "Current runner version");
665        raw(&mut feed, 10, 1, "##[group]Runner Image");
666        raw(&mut feed, 10, 2, "Image: ubuntu");
667        raw(&mut feed, 10, 2, "##[endgroup]");
668        raw(&mut feed, 10, 3, "##[group]Secret source");
669        raw(&mut feed, 10, 4, "Dependabot");
670        raw(&mut feed, 10, 4, "##[endgroup]");
671        raw(&mut feed, 10, 5, "Prepare workflow directory");
672        raw(&mut feed, 10, 10, "##[group]Run echo \"::group::bulk\"");
673        raw(&mut feed, 10, 10, "##[endgroup]");
674        raw(&mut feed, 10, 11, "##[group]bulk");
675        raw(&mut feed, 37, 0, "line 1");
676        raw(&mut feed, 37, 900, "line 2");
677        raw(&mut feed, 37, 910, "##[endgroup]");
678        raw(&mut feed, 37, 920, "##[group]Run echo \"::error::boom\"");
679        raw(&mut feed, 37, 920, "##[endgroup]");
680        raw(&mut feed, 37, 930, "##[error]boom");
681        raw(&mut feed, 37, 931, "##[error]boom again");
682        raw(&mut feed, 37, 940, "Cleaning up orphan processes");
683        let out = feed.finish();
684        let range = |title: &str| (find(&out, title).start_line, find(&out, title).end_line);
685        assert_eq!(range("Set up job"), (0, 6));
686        assert_eq!(range("Bulk output"), (6, 10));
687        assert_eq!(range("Fail"), (10, 14));
688        assert_eq!(range("Complete job"), (14, 14));
689        assert_eq!(find(&out, "Fail").error_count, 2);
690        assert!(find(&out, "Fail").open);
691        assert!(!find(&out, "Bulk output").open);
692        assert_eq!(out.focus_line, Some(11));
693        let bulk = find(&out, "bulk");
694        assert_eq!(bulk.parent, Some(find(&out, "Bulk output").id));
695        assert_eq!((bulk.header_line, bulk.end_line), (Some(7), 10));
696        assert_eq!(
697            find(&out, "Runner Image").parent,
698            Some(find(&out, "Set up job").id)
699        );
700    }
701
702    #[test]
703    fn run_logs_outline_plain_dialect_falls_back_to_timestamps_first_tied_step_wins() {
704        let mut feed = Feed::new(vec![
705            bound("pre", PipelineStatus::Success, (1, 0), (2, 0)),
706            bound("a", PipelineStatus::Success, (5, 0), (5, 0)),
707            bound("b", PipelineStatus::Success, (5, 0), (5, 0)),
708        ]);
709        feed.plain(t(1, 0));
710        feed.plain(t(3, 0));
711        feed.plain(t(5, 100));
712        feed.plain(t(5, 200));
713        let out = feed.finish();
714        let range = |title: &str| (find(&out, title).start_line, find(&out, title).end_line);
715        assert_eq!(range("pre"), (0, 2));
716        assert_eq!(range("a"), (2, 4));
717        assert_eq!(range("b"), (4, 4));
718    }
719
720    #[test]
721    fn run_logs_outline_skipped_step_does_not_steal_the_next_marker() {
722        let mut feed = Feed::new(vec![
723            bound("first", PipelineStatus::Success, (1, 0), (1, 0)),
724            bound("skipped", PipelineStatus::Skipped, (5, 0), (5, 0)),
725            bound("real", PipelineStatus::Success, (5, 0), (6, 0)),
726        ]);
727        raw(&mut feed, 1, 0, "setup");
728        raw(&mut feed, 5, 100, "##[group]Run make");
729        raw(&mut feed, 5, 200, "##[endgroup]");
730        raw(&mut feed, 5, 300, "output");
731        let out = feed.finish();
732        let range = |title: &str| (find(&out, title).start_line, find(&out, title).end_line);
733        assert_eq!(range("first"), (0, 1));
734        assert_eq!(range("skipped"), (1, 1));
735        assert_eq!(range("real"), (1, 3));
736    }
737
738    #[test]
739    fn run_logs_outline_markers_after_a_steps_finish_second_are_not_taken() {
740        let mut feed = Feed::new(vec![
741            bound("s0", PipelineStatus::Success, (1, 0), (1, 0)),
742            bound("a", PipelineStatus::Success, (2, 0), (2, 0)),
743            bound("b", PipelineStatus::Success, (3, 0), (4, 0)),
744        ]);
745        raw(&mut feed, 1, 0, "setup");
746        raw(&mut feed, 2, 100, "a output");
747        raw(&mut feed, 4, 500, "##[group]Run x");
748        raw(&mut feed, 4, 600, "##[endgroup]");
749        raw(&mut feed, 4, 700, "b output");
750        let out = feed.finish();
751        let range = |title: &str| (find(&out, title).start_line, find(&out, title).end_line);
752        assert_eq!(range("s0"), (0, 1));
753        assert_eq!(range("a"), (1, 2));
754        assert_eq!(range("b"), (2, 4));
755    }
756
757    #[test]
758    fn run_logs_outline_unreached_and_unstarted_steps_are_empty_at_the_end() {
759        let mut feed = Feed::new(vec![
760            StepBound {
761                started_ms: None,
762                ..step("none", PipelineStatus::Pending, 0)
763            },
764            step("late", PipelineStatus::Pending, 50),
765            step("a", PipelineStatus::Success, 5),
766        ]);
767        feed.plain(t(5, 0));
768        feed.plain(t(6, 0));
769        let out = feed.finish();
770        assert_eq!(
771            (find(&out, "a").start_line, find(&out, "a").end_line),
772            (0, 2)
773        );
774        assert_eq!(
775            (find(&out, "late").start_line, find(&out, "late").end_line),
776            (2, 2)
777        );
778        assert_eq!(
779            (find(&out, "none").start_line, find(&out, "none").end_line),
780            (2, 2)
781        );
782    }
783
784    #[test]
785    fn run_logs_outline_failed_step_is_open_and_success_is_closed() {
786        let mut feed = Feed::new(vec![
787            step("ok", PipelineStatus::Success, 1),
788            step("bad", PipelineStatus::Failed, 2),
789        ]);
790        feed.plain(t(1, 0));
791        feed.plain(t(2, 0));
792        let out = feed.finish();
793        assert!(!find(&out, "ok").open);
794        assert!(find(&out, "bad").open);
795
796        let mut feed = Feed::new(vec![step("only", PipelineStatus::Success, 1)]);
797        feed.plain(t(1, 0));
798        assert!(feed.finish().sections[0].open);
799    }
800
801    #[test]
802    fn run_logs_outline_focus_is_first_error_of_failed_step() {
803        let mut feed = Feed::new(vec![
804            step("early", PipelineStatus::Success, 1),
805            step("bad", PipelineStatus::Failed, 2),
806        ]);
807        for _ in 0..20_000 {
808            feed.error(t(1, 0));
809        }
810        feed.plain(t(2, 0));
811        feed.error(t(2, 1));
812        feed.error(t(2, 2));
813        let out = feed.finish();
814        assert_eq!(out.focus_line, Some(20_001));
815        assert_eq!(out.error_total, 20_002);
816        assert_eq!(out.error_lines.len(), MARKER_LINES_CAP);
817        assert_eq!(find(&out, "early").error_count, 20_000);
818        assert_eq!(find(&out, "bad").error_count, 2);
819    }
820
821    #[test]
822    fn run_logs_outline_focus_falls_back_to_first_error() {
823        let mut feed = Feed::new(vec![
824            step("bad", PipelineStatus::Failed, 1),
825            step("ok", PipelineStatus::Success, 2),
826        ]);
827        feed.plain(t(1, 0));
828        feed.plain(t(2, 0));
829        feed.error(t(2, 1));
830        assert_eq!(feed.finish().focus_line, Some(2));
831
832        let mut feed = Feed::new(vec![]);
833        feed.plain(None);
834        let out = feed.finish();
835        assert_eq!(out.focus_line, None);
836        assert!(out.sections.is_empty());
837    }
838
839    #[test]
840    fn run_logs_outline_focus_lands_on_the_end_of_a_failed_step_without_error_lines() {
841        let mut feed = Feed::new(vec![
842            step("ok", PipelineStatus::Success, 1),
843            step("bad", PipelineStatus::Failed, 2),
844            step("after", PipelineStatus::Success, 3),
845        ]);
846        feed.plain(t(1, 0));
847        feed.plain(t(2, 0));
848        feed.plain(t(2, 1));
849        feed.plain(t(3, 0));
850        let out = feed.finish();
851        assert_eq!(out.error_total, 0);
852        assert_eq!(out.focus_line, Some(2));
853    }
854
855    #[test]
856    fn run_logs_outline_counts_errors_beyond_the_listed_cap() {
857        let mut feed = Feed::new(vec![
858            step("one", PipelineStatus::Success, 1),
859            step("two", PipelineStatus::Success, 2),
860        ]);
861        for _ in 0..MARKER_LINES_CAP {
862            feed.error(t(1, 0));
863        }
864        feed.plain(t(2, 0));
865        feed.group(t(2, 1), "late group");
866        for _ in 0..5 {
867            feed.error(t(2, 2));
868        }
869        feed.end(t(2, 3));
870        let out = feed.finish();
871        let group = find(&out, "late group");
872        assert_eq!(group.error_count, 5);
873        assert!(group.open);
874        assert_eq!(out.error_total as usize, MARKER_LINES_CAP + 5);
875        assert_eq!(out.error_lines.len(), MARKER_LINES_CAP);
876    }
877
878    #[test]
879    fn run_logs_outline_group_crossing_step_end_is_clamped_and_reparented() {
880        let mut feed = Feed::new(vec![
881            step("a", PipelineStatus::Success, 1),
882            step("b", PipelineStatus::Success, 2),
883        ]);
884        feed.group(t(1, 0), "outer");
885        feed.plain(t(1, 5));
886        feed.group(t(1, 6), "inner");
887        feed.plain(t(2, 0));
888        feed.group(t(2, 1), "third");
889        feed.plain(t(2, 2));
890        let out = feed.finish();
891        let a = find(&out, "a");
892        let b = find(&out, "b");
893        let outer = find(&out, "outer");
894        let inner = find(&out, "inner");
895        let third = find(&out, "third");
896        assert_eq!((a.end_line, b.start_line), (3, 3));
897        assert_eq!(outer.parent, Some(a.id));
898        assert_eq!(outer.end_line, 3);
899        assert_eq!(inner.parent, Some(outer.id));
900        assert_eq!(inner.end_line, 3);
901        assert_eq!(third.parent, Some(b.id));
902        assert_eq!(third.end_line, 6);
903        for (i, section) in out.sections.iter().enumerate() {
904            assert_eq!(section.id as usize, i);
905            assert!(section.parent.is_none_or(|p| p < section.id));
906        }
907    }
908
909    #[test]
910    fn run_logs_outline_group_duration_uses_first_and_last_timestamps() {
911        let mut feed = Feed::new(vec![]);
912        feed.group(t(1, 0), "g");
913        feed.plain(t(2, 0));
914        feed.end(t(3, 500));
915        let out = feed.finish();
916        assert_eq!(find(&out, "g").duration_ms, Some(2500));
917    }
918
919    #[test]
920    fn run_logs_outline_caps_sections_at_max() {
921        let steps = (0..25_000)
922            .map(|i| step(&format!("s{i}"), PipelineStatus::Success, i))
923            .collect();
924        let out = Feed::new(steps).finish();
925        assert_eq!(out.sections.len(), MAX_SECTIONS);
926    }
927
928    #[test]
929    fn run_logs_outline_ignored_group_ends_do_not_close_tracked_groups() {
930        let steps = (0..MAX_SECTIONS - 2)
931            .map(|i| step(&format!("s{i}"), PipelineStatus::Success, i as i64))
932            .collect();
933        let mut feed = Feed::new(steps);
934        feed.group(None, "A");
935        feed.group(None, "B");
936        feed.group(None, "C");
937        feed.group(None, "D");
938        feed.end(None);
939        feed.end(None);
940        feed.end(None);
941        feed.plain(None);
942        feed.end(None);
943        let out = feed.finish();
944        assert_eq!(out.sections.len(), MAX_SECTIONS);
945        let a = find(&out, "A");
946        let b = find(&out, "B");
947        assert_eq!((a.header_line, a.end_line), (Some(0), 5));
948        assert_eq!((b.header_line, b.end_line), (Some(1), 4));
949        assert_eq!(b.parent, Some(a.id));
950    }
951}