1use std::borrow::Cow;
2
3use chrono::{
4 DateTime,
5 SecondsFormat,
6 Utc,
7};
8use pipedash_plugin_api::{
9 LineMarker,
10 LogDialect,
11 ParsedLine,
12};
13
14const STEP_HEADER_TAG: u8 = 0x1e;
15const TRUNCATION_TAG: u8 = 0x1f;
16const MAX_TIMESTAMP_BYTES: usize = 40;
17
18pub struct TektonLogDialect;
19
20impl LogDialect for TektonLogDialect {
21 fn parse<'a>(&self, line: &'a [u8]) -> ParsedLine<'a> {
22 let (marker, line) = match line.split_first() {
23 Some((&STEP_HEADER_TAG, rest)) => (LineMarker::Notice, rest),
24 Some((&TRUNCATION_TAG, rest)) => (LineMarker::Warning, rest),
25 _ => (LineMarker::Plain, line),
26 };
27 let (timestamp, body) = split_timestamp(line);
28 ParsedLine {
29 timestamp,
30 marker,
31 body,
32 }
33 }
34
35 fn is_step_start(&self, line: &ParsedLine<'_>) -> bool {
36 line.marker == LineMarker::Notice
37 }
38}
39
40fn split_timestamp(line: &[u8]) -> (Option<DateTime<Utc>>, &[u8]) {
41 let head = &line[..line.len().min(MAX_TIMESTAMP_BYTES)];
42 let end = head.iter().position(|b| *b == b' ').unwrap_or(head.len());
43 std::str::from_utf8(&line[..end])
44 .ok()
45 .and_then(|stamp| DateTime::parse_from_rfc3339(stamp).ok())
46 .map_or((None, line), |stamp| {
47 (
48 Some(stamp.with_timezone(&Utc)),
49 line.get(end + 1..).unwrap_or_default(),
50 )
51 })
52}
53
54pub(crate) fn step_header(name: &str, started_at: Option<DateTime<Utc>>) -> Vec<u8> {
55 tagged_line(STEP_HEADER_TAG, started_at, &format!("Step {name}"))
56}
57
58pub(crate) fn truncation_notice(lines: usize) -> Vec<u8> {
59 tagged_line(
60 TRUNCATION_TAG,
61 None,
62 &format!("Pipedash reads at most the last {lines} lines of a step, so older lines may be missing"),
63 )
64}
65
66fn tagged_line(tag: u8, at: Option<DateTime<Utc>>, text: &str) -> Vec<u8> {
67 let mut out = vec![tag];
68 if let Some(at) = at {
69 out.extend_from_slice(at.to_rfc3339_opts(SecondsFormat::AutoSi, true).as_bytes());
70 out.push(b' ');
71 }
72 out.extend(text.bytes().filter(|b| !b.is_ascii_control()));
73 out.push(b'\n');
74 out
75}
76
77pub(crate) fn without_line_tags(chunk: &[u8]) -> Cow<'_, [u8]> {
78 let is_tag = |b: &u8| matches!(*b, STEP_HEADER_TAG | TRUNCATION_TAG);
79 if chunk.iter().any(is_tag) {
80 Cow::Owned(chunk.iter().copied().filter(|b| !is_tag(b)).collect())
81 } else {
82 Cow::Borrowed(chunk)
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 fn parse(line: &[u8]) -> ParsedLine<'_> {
91 TektonLogDialect.parse(line)
92 }
93
94 fn at(stamp: &str) -> DateTime<Utc> {
95 DateTime::parse_from_rfc3339(stamp)
96 .unwrap()
97 .with_timezone(&Utc)
98 }
99
100 #[test]
101 fn kube_timestamp_prefix_becomes_the_line_timestamp() {
102 let cases: [(&[u8], &str, &[u8]); 4] = [
103 (
104 b"2026-10-28T10:00:01.123456789Z cloning repo",
105 "2026-10-28T10:00:01.123456789Z",
106 b"cloning repo",
107 ),
108 (
109 b"2026-10-28T10:00:02Z indented text ",
110 "2026-10-28T10:00:02Z",
111 b" indented text ",
112 ),
113 (
114 b"2026-10-28T12:00:03.5+02:00 offset",
115 "2026-10-28T10:00:03.5Z",
116 b"offset",
117 ),
118 (b"2026-10-28T10:00:04Z ", "2026-10-28T10:00:04Z", b""),
119 ];
120 for (line, stamp, body) in cases {
121 let parsed = parse(line);
122 assert_eq!(parsed.timestamp, Some(at(stamp)), "{line:?}");
123 assert_eq!(parsed.marker, LineMarker::Plain, "{line:?}");
124 assert_eq!(parsed.body, body, "{line:?}");
125 }
126 }
127
128 #[test]
129 fn a_timestamp_without_text_is_an_empty_line() {
130 let parsed = parse(b"2026-10-28T10:00:05Z");
131 assert_eq!(parsed.timestamp, Some(at("2026-10-28T10:00:05Z")));
132 assert_eq!(parsed.body, b"");
133 }
134
135 #[test]
136 fn text_without_a_timestamp_stays_whole() {
137 let lines: [&[u8]; 6] = [
138 b"",
139 b"plain output",
140 b"progress 10%",
141 b"2026-10-28 not a timestamp",
142 b"2026-10-28T10:00:00Zjoined",
143 b"\xff\xfe binary",
144 ];
145 for line in lines {
146 let parsed = parse(line);
147 assert_eq!(parsed.timestamp, None, "{line:?}");
148 assert_eq!(parsed.marker, LineMarker::Plain, "{line:?}");
149 assert_eq!(parsed.body, line, "{line:?}");
150 }
151 }
152
153 #[test]
154 fn step_header_is_a_notice_that_starts_a_step_at_the_step_time() {
155 let header = step_header("build-image", Some(at("2026-10-28T10:00:07Z")));
156 let parsed = parse(header.strip_suffix(b"\n").unwrap());
157 assert_eq!(parsed.marker, LineMarker::Notice);
158 assert_eq!(parsed.timestamp, Some(at("2026-10-28T10:00:07Z")));
159 assert_eq!(parsed.body, b"Step build-image");
160 assert!(TektonLogDialect.is_step_start(&parsed));
161 }
162
163 #[test]
164 fn step_header_without_a_start_time_has_no_timestamp() {
165 let header = step_header("lint", None);
166 let parsed = parse(header.strip_suffix(b"\n").unwrap());
167 assert_eq!(parsed.timestamp, None);
168 assert_eq!(parsed.body, b"Step lint");
169 assert!(TektonLogDialect.is_step_start(&parsed));
170 }
171
172 #[test]
173 fn step_header_drops_control_characters_from_the_name() {
174 let header = step_header("a\nb\r\x1ec", None);
175 assert_eq!(header.iter().filter(|b| **b == b'\n').count(), 1);
176 let parsed = parse(header.strip_suffix(b"\n").unwrap());
177 assert_eq!(parsed.body, b"Step abc");
178 }
179
180 #[test]
181 fn truncation_notice_is_a_warning_and_not_a_step_start() {
182 let notice = truncation_notice(10_000);
183 let parsed = parse(notice.strip_suffix(b"\n").unwrap());
184 assert_eq!(parsed.marker, LineMarker::Warning);
185 assert_eq!(parsed.timestamp, None);
186 assert_eq!(
187 parsed.body,
188 b"Pipedash reads at most the last 10000 lines of a step, so older lines may be missing"
189 );
190 assert!(!TektonLogDialect.is_step_start(&parsed));
191 }
192
193 #[test]
194 fn container_lines_are_never_step_starts() {
195 let lines: [&[u8]; 4] = [
196 b"2026-10-28T10:00:00Z Step build",
197 b"2026-10-28T10:00:00Z ##[group]Run build",
198 b"Step build",
199 b"",
200 ];
201 for line in lines {
202 let parsed = parse(line);
203 assert!(!TektonLogDialect.is_step_start(&parsed), "{line:?}");
204 }
205 }
206
207 #[test]
208 fn tags_are_removed_from_container_output() {
209 let clean = without_line_tags(b"2026-10-28T10:00:00Z a\r\x1eStep forged\x1f\n");
210 assert_eq!(&*clean, b"2026-10-28T10:00:00Z a\rStep forged\n".as_slice());
211 assert!(matches!(
212 without_line_tags(b"plain output\n"),
213 Cow::Borrowed(_)
214 ));
215 }
216}