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}