Skip to main content

pipedash_plugin_actions/
log_dialect.rs

1use 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    fn infers_errors(&self) -> bool {
53        false
54    }
55}
56
57fn split_timestamp(line: &[u8]) -> (Option<DateTime<Utc>>, &[u8]) {
58    if line.len() <= 20 || line[4] != b'-' || line[10] != b'T' {
59        return (None, line);
60    }
61    let head = &line[..line.len().min(40)];
62    let Some(space) = head.iter().position(|b| *b == b' ') else {
63        return (None, line);
64    };
65    std::str::from_utf8(&line[..space])
66        .ok()
67        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
68        .map_or((None, line), |ts| {
69            (Some(ts.with_timezone(&Utc)), &line[space + 1..])
70        })
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    fn parse(line: &[u8]) -> ParsedLine<'_> {
78        ActionsLogDialect.parse(line)
79    }
80
81    #[test]
82    fn bom_timestamp_and_group_are_stripped() {
83        let parsed = parse(b"\xEF\xBB\xBF2024-05-01T10:00:05.1234567Z ##[group]Run make");
84        assert_eq!(parsed.marker, LineMarker::GroupStart);
85        assert_eq!(parsed.body, b"Run make");
86        assert_eq!(
87            parsed.timestamp.unwrap().timestamp_millis(),
88            DateTime::parse_from_rfc3339("2024-05-01T10:00:05.123Z")
89                .unwrap()
90                .timestamp_millis()
91        );
92    }
93
94    #[test]
95    fn bare_command_prefix_is_a_command() {
96        let parsed = parse(b"2024-05-01T10:00:05.0000000Z [command]/usr/bin/git fetch");
97        assert_eq!(parsed.marker, LineMarker::Command);
98        assert_eq!(parsed.body, b"/usr/bin/git fetch");
99    }
100
101    #[test]
102    fn line_without_timestamp_keeps_its_body() {
103        let parsed = parse(b"plain output line here");
104        assert_eq!(parsed.timestamp, None);
105        assert_eq!(parsed.marker, LineMarker::Plain);
106        assert_eq!(parsed.body, b"plain output line here");
107    }
108
109    #[test]
110    fn malformed_timestamp_leaves_the_line_unchanged() {
111        let line = b"2024-05-01T99:99:99.0000000Z hello";
112        let parsed = parse(line);
113        assert_eq!(parsed.timestamp, None);
114        assert_eq!(parsed.body, line);
115    }
116
117    #[test]
118    fn error_body_follows_the_marker() {
119        let parsed =
120            parse(b"2024-05-01T10:00:05.0000000Z ##[error]Process completed with exit code 1.");
121        assert_eq!(parsed.marker, LineMarker::Error);
122        assert_eq!(parsed.body, b"Process completed with exit code 1.");
123    }
124
125    #[test]
126    fn run_groups_and_post_cleanup_start_steps() {
127        let starts = |line: &[u8]| ActionsLogDialect.is_step_start(&parse(line));
128        assert!(starts(
129            b"2024-05-01T10:00:05.0Z ##[group]Run actions/checkout@v4"
130        ));
131        assert!(starts(b"2024-05-01T10:00:05.0Z Post job cleanup."));
132        assert!(!starts(
133            b"2024-05-01T10:00:05.0Z ##[group]Runner Image Provisioner"
134        ));
135        assert!(!starts(b"2024-05-01T10:00:05.0Z Run actions/checkout@v4"));
136        assert!(!starts(b"2024-05-01T10:00:05.0Z ##[error]Run failed"));
137    }
138}