pipedash_plugin_actions/
log_dialect.rs1use chrono::{
2 DateTime,
3 Utc,
4};
5use pipedash_plugin_api::{
6 LineMarker,
7 LogDialect,
8 ParsedLine,
9};
10
11const MARKERS: [(&[u8], LineMarker); 8] = [
12 (b"##[group]", LineMarker::GroupStart),
13 (b"##[endgroup]", LineMarker::GroupEnd),
14 (b"##[error]", LineMarker::Error),
15 (b"##[warning]", LineMarker::Warning),
16 (b"##[notice]", LineMarker::Notice),
17 (b"##[debug]", LineMarker::Debug),
18 (b"##[command]", LineMarker::Command),
19 (b"[command]", LineMarker::Command),
20];
21
22pub struct ActionsLogDialect;
23
24impl LogDialect for ActionsLogDialect {
25 fn parse<'a>(&self, line: &'a [u8]) -> ParsedLine<'a> {
26 let line = line.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(line);
27 let (timestamp, body) = split_timestamp(line);
28 for (prefix, marker) in MARKERS {
29 if let Some(rest) = body.strip_prefix(prefix) {
30 return ParsedLine {
31 timestamp,
32 marker,
33 body: rest,
34 };
35 }
36 }
37 ParsedLine {
38 timestamp,
39 marker: LineMarker::Plain,
40 body,
41 }
42 }
43
44 fn is_step_start(&self, line: &ParsedLine<'_>) -> bool {
45 match line.marker {
46 LineMarker::GroupStart => line.body.starts_with(b"Run "),
47 LineMarker::Plain => line.body == b"Post job cleanup.",
48 _ => false,
49 }
50 }
51}
52
53fn split_timestamp(line: &[u8]) -> (Option<DateTime<Utc>>, &[u8]) {
54 if line.len() <= 20 || line[4] != b'-' || line[10] != b'T' {
55 return (None, line);
56 }
57 let head = &line[..line.len().min(40)];
58 let Some(space) = head.iter().position(|b| *b == b' ') else {
59 return (None, line);
60 };
61 std::str::from_utf8(&line[..space])
62 .ok()
63 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
64 .map_or((None, line), |ts| {
65 (Some(ts.with_timezone(&Utc)), &line[space + 1..])
66 })
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 fn parse(line: &[u8]) -> ParsedLine<'_> {
74 ActionsLogDialect.parse(line)
75 }
76
77 #[test]
78 fn bom_timestamp_and_group_are_stripped() {
79 let parsed = parse(b"\xEF\xBB\xBF2024-05-01T10:00:05.1234567Z ##[group]Run make");
80 assert_eq!(parsed.marker, LineMarker::GroupStart);
81 assert_eq!(parsed.body, b"Run make");
82 assert_eq!(
83 parsed.timestamp.unwrap().timestamp_millis(),
84 DateTime::parse_from_rfc3339("2024-05-01T10:00:05.123Z")
85 .unwrap()
86 .timestamp_millis()
87 );
88 }
89
90 #[test]
91 fn bare_command_prefix_is_a_command() {
92 let parsed = parse(b"2024-05-01T10:00:05.0000000Z [command]/usr/bin/git fetch");
93 assert_eq!(parsed.marker, LineMarker::Command);
94 assert_eq!(parsed.body, b"/usr/bin/git fetch");
95 }
96
97 #[test]
98 fn line_without_timestamp_keeps_its_body() {
99 let parsed = parse(b"plain output line here");
100 assert_eq!(parsed.timestamp, None);
101 assert_eq!(parsed.marker, LineMarker::Plain);
102 assert_eq!(parsed.body, b"plain output line here");
103 }
104
105 #[test]
106 fn malformed_timestamp_leaves_the_line_unchanged() {
107 let line = b"2024-05-01T99:99:99.0000000Z hello";
108 let parsed = parse(line);
109 assert_eq!(parsed.timestamp, None);
110 assert_eq!(parsed.body, line);
111 }
112
113 #[test]
114 fn error_body_follows_the_marker() {
115 let parsed =
116 parse(b"2024-05-01T10:00:05.0000000Z ##[error]Process completed with exit code 1.");
117 assert_eq!(parsed.marker, LineMarker::Error);
118 assert_eq!(parsed.body, b"Process completed with exit code 1.");
119 }
120
121 #[test]
122 fn run_groups_and_post_cleanup_start_steps() {
123 let starts = |line: &[u8]| ActionsLogDialect.is_step_start(&parse(line));
124 assert!(starts(
125 b"2024-05-01T10:00:05.0Z ##[group]Run actions/checkout@v4"
126 ));
127 assert!(starts(b"2024-05-01T10:00:05.0Z Post job cleanup."));
128 assert!(!starts(
129 b"2024-05-01T10:00:05.0Z ##[group]Runner Image Provisioner"
130 ));
131 assert!(!starts(b"2024-05-01T10:00:05.0Z Run actions/checkout@v4"));
132 assert!(!starts(b"2024-05-01T10:00:05.0Z ##[error]Run failed"));
133 }
134}