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 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}