pipedash_plugin_buildkite/
log_dialect.rs1use 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}