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