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