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