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