1use pipedash_plugin_api::{
2 LineMarker,
3 LogDialect,
4 ParsedLine,
5};
6
7use crate::types::UpdateLog;
8
9const TAGS: [(u8, LineMarker); 4] = [
10 (b'.', LineMarker::Plain),
11 (b'$', LineMarker::Command),
12 (b'!', LineMarker::Error),
13 (b'?', LineMarker::Warning),
14];
15
16pub struct KomodoLogDialect;
17
18impl LogDialect for KomodoLogDialect {
19 fn parse<'a>(&self, line: &'a [u8]) -> ParsedLine<'a> {
20 let plain = ParsedLine {
21 timestamp: None,
22 marker: LineMarker::Plain,
23 body: line,
24 };
25 let Some((tag, rest)) = line.split_first() else {
26 return plain;
27 };
28 let Some(text) = rest.strip_prefix(b" ") else {
29 return plain;
30 };
31 match TAGS.iter().find(|(known, _)| known == tag) {
32 Some((_, LineMarker::Command)) => ParsedLine {
33 marker: LineMarker::Command,
34 ..plain
35 },
36 Some((_, marker)) => ParsedLine {
37 timestamp: None,
38 marker: *marker,
39 body: text,
40 },
41 None => plain,
42 }
43 }
44}
45
46pub(crate) fn stage_lines(log: &UpdateLog) -> impl Iterator<Item = (LineMarker, &str)> {
47 let stderr = if log.success {
48 LineMarker::Warning
49 } else {
50 LineMarker::Error
51 };
52 [
53 (LineMarker::Command, log.command.as_str()),
54 (LineMarker::Plain, log.stdout.as_str()),
55 (stderr, log.stderr.as_str()),
56 ]
57 .into_iter()
58 .flat_map(|(marker, text)| text.lines().map(move |line| (marker, line)))
59}
60
61pub(crate) fn push_line(out: &mut Vec<u8>, marker: LineMarker, line: &str) {
62 let tag = TAGS
63 .iter()
64 .find(|(_, known)| *known == marker)
65 .map_or(b'.', |(tag, _)| *tag);
66 let line = line.strip_suffix('\r').unwrap_or(line);
67 out.extend_from_slice(&[tag, b' ']);
68 for (index, segment) in line.split('\r').enumerate() {
69 if index > 0 {
70 out.extend_from_slice(&[b'\r', tag, b' ']);
71 }
72 out.extend_from_slice(segment.as_bytes());
73 }
74 out.push(b'\n');
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 fn parse(line: &[u8]) -> ParsedLine<'_> {
82 KomodoLogDialect.parse(line)
83 }
84
85 fn log(success: bool, command: &str, stdout: &str, stderr: &str) -> UpdateLog {
86 UpdateLog {
87 stage: "Compose Up".to_string(),
88 command: command.to_string(),
89 stdout: stdout.to_string(),
90 stderr: stderr.to_string(),
91 success,
92 start_ts: 0,
93 end_ts: 0,
94 }
95 }
96
97 fn encode(log: &UpdateLog) -> Vec<u8> {
98 let mut out = Vec::new();
99 for (marker, line) in stage_lines(log) {
100 push_line(&mut out, marker, line);
101 }
102 out
103 }
104
105 fn parsed_lines(encoded: &[u8]) -> Vec<(LineMarker, String)> {
106 encoded
107 .strip_suffix(b"\n")
108 .into_iter()
109 .flat_map(|text| text.split(|b| *b == b'\n'))
110 .map(|line| {
111 let parsed = parse(line);
112 (
113 parsed.marker,
114 String::from_utf8_lossy(parsed.body).into_owned(),
115 )
116 })
117 .collect()
118 }
119
120 #[test]
121 fn every_tag_maps_to_its_marker_and_strips_itself() {
122 let cases: [(&[u8], LineMarker, &[u8]); 4] = [
123 (b". pulling image", LineMarker::Plain, b"pulling image"),
124 (
125 b"$ docker compose up -d",
126 LineMarker::Command,
127 b"$ docker compose up -d",
128 ),
129 (b"! no such service", LineMarker::Error, b"no such service"),
130 (b"? network exists", LineMarker::Warning, b"network exists"),
131 ];
132 for (line, marker, body) in cases {
133 let parsed = parse(line);
134 assert_eq!(parsed.marker, marker, "{line:?}");
135 assert_eq!(parsed.body, body, "{line:?}");
136 assert_eq!(parsed.timestamp, None, "{line:?}");
137 }
138 }
139
140 #[test]
141 fn tagged_lines_without_text_keep_their_marker() {
142 for (line, marker) in [
143 (b". ".as_slice(), LineMarker::Plain),
144 (b"! ", LineMarker::Error),
145 (b"? ", LineMarker::Warning),
146 ] {
147 let parsed = parse(line);
148 assert_eq!(parsed.marker, marker, "{line:?}");
149 assert_eq!(parsed.body, b"", "{line:?}");
150 }
151 }
152
153 #[test]
154 fn lines_without_a_known_tag_and_a_space_stay_plain() {
155 let lines: [&[u8]; 8] = [
156 b"",
157 b"plain output",
158 b"!no space",
159 b"$docker",
160 b"x unknown tag",
161 b"$",
162 b"!",
163 b" indented",
164 ];
165 for line in lines {
166 let parsed = parse(line);
167 assert_eq!(parsed.marker, LineMarker::Plain, "{line:?}");
168 assert_eq!(parsed.body, line, "{line:?}");
169 }
170 }
171
172 #[test]
173 fn stderr_is_an_error_in_a_failed_stage_and_a_warning_in_a_successful_one() {
174 let failed = encode(&log(false, "docker compose up", "out", "boom"));
175 let passed = encode(&log(true, "docker compose up", "out", "boom"));
176
177 assert_eq!(
178 parsed_lines(&failed),
179 [
180 (LineMarker::Command, "$ docker compose up".to_string()),
181 (LineMarker::Plain, "out".to_string()),
182 (LineMarker::Error, "boom".to_string()),
183 ]
184 );
185 assert_eq!(
186 parsed_lines(&passed),
187 [
188 (LineMarker::Command, "$ docker compose up".to_string()),
189 (LineMarker::Plain, "out".to_string()),
190 (LineMarker::Warning, "boom".to_string()),
191 ]
192 );
193 }
194
195 #[test]
196 fn every_line_of_a_multi_line_text_carries_its_tag() {
197 let encoded = encode(&log(
198 false,
199 "set -e\ndocker compose up",
200 "a\n\nb\n",
201 "x\r\ny",
202 ));
203
204 assert_eq!(
205 encoded,
206 b"$ set -e\n$ docker compose up\n. a\n. \n. b\n! x\n! y\n"
207 );
208 }
209
210 #[test]
211 fn empty_command_and_streams_write_nothing() {
212 assert!(encode(&log(true, "", "", "")).is_empty());
213 }
214
215 #[test]
216 fn a_tagged_line_that_looks_like_output_of_another_stream_stays_in_its_stream() {
217 let encoded = encode(&log(false, "", "! not an error", ""));
218
219 assert_eq!(
220 parsed_lines(&encoded),
221 [(LineMarker::Plain, "! not an error".to_string())]
222 );
223 }
224
225 #[test]
226 fn carriage_returns_keep_the_tag_on_the_part_that_stays_visible() {
227 let mut out = Vec::new();
228 push_line(&mut out, LineMarker::Error, "10%\r55%\r100%\r");
229 assert_eq!(out, b"! 10%\r! 55%\r! 100%\n");
230
231 let line = out.strip_suffix(b"\n").unwrap();
232 let visible = line
233 .rsplit(|b| *b == b'\r')
234 .find(|segment| !segment.is_empty())
235 .unwrap();
236 let parsed = parse(visible);
237 assert_eq!(parsed.marker, LineMarker::Error);
238 assert_eq!(parsed.body, b"100%");
239 }
240}