Skip to main content

pipedash_plugin_buildkite/
log_dialect.rs

1use chrono::{
2    DateTime,
3    Utc,
4};
5use pipedash_plugin_api::{
6    LineMarker,
7    LogDialect,
8    ParsedLine,
9};
10
11const TIMESTAMP_START: &[u8] = b"\x1b_bk;t=";
12
13const SECTIONS: [(&[u8], LineMarker); 4] = [
14    (b"^^^ +++", LineMarker::OpenSection),
15    (b"--- ", LineMarker::SectionStart { open: false }),
16    (b"~~~ ", LineMarker::SectionStart { open: false }),
17    (b"+++ ", LineMarker::SectionStart { open: true }),
18];
19
20const MESSAGES: [(&[u8], LineMarker); 3] = [
21    ("🚨 Error: ".as_bytes(), LineMarker::Error),
22    ("⚠️ Warning: ".as_bytes(), LineMarker::Warning),
23    (b"$", LineMarker::Command),
24];
25
26pub struct BuildkiteLogDialect;
27
28impl LogDialect for BuildkiteLogDialect {
29    fn parse<'a>(&self, line: &'a [u8]) -> ParsedLine<'a> {
30        let (timestamp, body) = split_timestamp(line);
31        let text = skip_sgr(body);
32        for (prefix, marker) in SECTIONS {
33            if let Some(rest) = text.strip_prefix(prefix) {
34                return ParsedLine {
35                    timestamp,
36                    marker,
37                    body: rest.strip_prefix(b" ").unwrap_or(rest),
38                };
39            }
40        }
41        let marker = MESSAGES
42            .iter()
43            .find(|(prefix, marker)| {
44                text.strip_prefix(*prefix).is_some_and(|rest| {
45                    *marker != LineMarker::Command || skip_sgr(rest).starts_with(b" ")
46                })
47            })
48            .map_or(LineMarker::Plain, |(_, marker)| *marker);
49        ParsedLine {
50            timestamp,
51            marker,
52            body,
53        }
54    }
55}
56
57fn split_timestamp(line: &[u8]) -> (Option<DateTime<Utc>>, &[u8]) {
58    let Some(rest) = line.strip_prefix(TIMESTAMP_START) else {
59        return (None, line);
60    };
61    let Some(end) = rest.iter().position(|b| *b == b'\x07') else {
62        return (None, line);
63    };
64    std::str::from_utf8(&rest[..end])
65        .ok()
66        .and_then(|ms| ms.parse::<i64>().ok())
67        .and_then(DateTime::from_timestamp_millis)
68        .map_or((None, line), |ts| (Some(ts), &rest[end + 1..]))
69}
70
71fn skip_sgr(mut text: &[u8]) -> &[u8] {
72    while let Some(rest) = text.strip_prefix(b"\x1b[") {
73        let Some(end) = rest.iter().position(|b| *b == b'm') else {
74            break;
75        };
76        if !rest[..end].iter().all(|b| b.is_ascii_digit() || *b == b';') {
77            break;
78        }
79        text = &rest[end + 1..];
80    }
81    text
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    fn parse(line: &[u8]) -> ParsedLine<'_> {
89        BuildkiteLogDialect.parse(line)
90    }
91
92    #[test]
93    fn ansi_timestamp_is_parsed_and_stripped() {
94        let parsed = parse(b"\x1b_bk;t=1745322210698\x07total 36");
95        assert_eq!(parsed.marker, LineMarker::Plain);
96        assert_eq!(parsed.body, b"total 36");
97        assert_eq!(
98            parsed.timestamp.unwrap().timestamp_millis(),
99            1_745_322_210_698
100        );
101    }
102
103    #[test]
104    fn line_without_a_valid_timestamp_keeps_its_bytes() {
105        for line in [
106            b"plain output".as_slice(),
107            b"\x1b_bk;t=12ab\x07text",
108            b"\x1b_bk;t=1745322210698 no bell",
109        ] {
110            let parsed = parse(line);
111            assert_eq!(parsed.timestamp, None);
112            assert_eq!(parsed.body, line);
113        }
114    }
115
116    #[test]
117    fn group_headers_start_sections() {
118        let cases: [(&[u8], LineMarker, &[u8]); 5] = [
119            (
120                b"\x1b_bk;t=1\x07~~~ Running global environment hook",
121                LineMarker::SectionStart { open: false },
122                b"Running global environment hook",
123            ),
124            (
125                b"\x1b_bk;t=1\x07--- :package: Build",
126                LineMarker::SectionStart { open: false },
127                b":package: Build",
128            ),
129            (
130                b"+++ Running \x1b[33mspecs\x1b[0m",
131                LineMarker::SectionStart { open: true },
132                b"Running \x1b[33mspecs\x1b[0m",
133            ),
134            (b"^^^ +++", LineMarker::OpenSection, b""),
135            (
136                b"\x1b_bk;t=1\x07^^^ +++ Bundling failed",
137                LineMarker::OpenSection,
138                b"Bundling failed",
139            ),
140        ];
141        for (line, marker, body) in cases {
142            let parsed = parse(line);
143            assert_eq!(parsed.marker, marker, "{line:?}");
144            assert_eq!(parsed.body, body, "{line:?}");
145        }
146    }
147
148    #[test]
149    fn dashes_without_a_title_are_output() {
150        for line in [b"---".as_slice(), b"---not a header", b"+++", b"~~~~"] {
151            assert_eq!(parse(line).marker, LineMarker::Plain, "{line:?}");
152        }
153    }
154
155    #[test]
156    fn agent_errors_warnings_and_prompts_keep_their_colors() {
157        let cases: [(&[u8], LineMarker); 6] = [
158            (
159                "\x1b_bk;t=1\x07\x1b[31m🚨 Error: The command exited with status 1\x1b[0m"
160                    .as_bytes(),
161                LineMarker::Error,
162            ),
163            (
164                "🚨 Error: The command exited with status 2".as_bytes(),
165                LineMarker::Error,
166            ),
167            (
168                "\x1b[33m⚠️ Warning: Ignored FOO\x1b[0m".as_bytes(),
169                LineMarker::Warning,
170            ),
171            (b"\x1b[90m$\x1b[0m ./script.sh", LineMarker::Command),
172            (b"$ make test", LineMarker::Command),
173            (b"$HOME is set", LineMarker::Plain),
174        ];
175        for (line, marker) in cases {
176            let parsed = parse(line);
177            assert_eq!(parsed.marker, marker, "{line:?}");
178            assert_eq!(
179                parsed.body,
180                line.strip_prefix(b"\x1b_bk;t=1\x07").unwrap_or(line)
181            );
182        }
183    }
184}