Skip to main content

pipedash_plugin_komodo/
log_dialect.rs

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}