pipedash-plugin-jenkins 0.6.0

Jenkins plugin for Pipedash
Documentation
use pipedash_plugin_api::{
    LineMarker,
    LogDialect,
    ParsedLine,
};

const PIPELINE_PREFIX: &[u8] = b"[Pipeline] ";
const BODY_START: &[u8] = b"{ (";
const STAGE_END: &[u8] = b"// stage";
const SHELL_STEP: &[u8] = b"sh";
const PARALLEL_BRANCH: &[u8] = b"Branch: ";
const ERROR_PREFIX: &[u8] = b"ERROR: ";
const FAILED_BUILD: &[u8] = b"Finished: FAILURE";
const TRACED_COMMAND: &[u8] = b"+ ";

pub struct JenkinsLogDialect;

impl LogDialect for JenkinsLogDialect {
    fn parse<'a>(&self, line: &'a [u8]) -> ParsedLine<'a> {
        let (marker, body) = match line.strip_prefix(PIPELINE_PREFIX) {
            Some(step) => classify_step(line, step),
            None => (classify_output(line), line),
        };
        ParsedLine {
            timestamp: None,
            marker,
            body,
        }
    }
}

fn classify_step<'a>(line: &'a [u8], step: &'a [u8]) -> (LineMarker, &'a [u8]) {
    if step == STAGE_END {
        (LineMarker::GroupEnd, &[])
    } else if step == SHELL_STEP {
        (LineMarker::Command, line)
    } else if let Some(name) = stage_name(step) {
        (LineMarker::SectionStart { open: false }, name)
    } else {
        (LineMarker::Plain, line)
    }
}

fn stage_name(step: &[u8]) -> Option<&[u8]> {
    step.strip_prefix(BODY_START)?
        .strip_suffix(b")")
        .filter(|name| !name.is_empty() && !name.starts_with(PARALLEL_BRANCH))
}

fn classify_output(line: &[u8]) -> LineMarker {
    if line.starts_with(ERROR_PREFIX) || line == FAILED_BUILD {
        LineMarker::Error
    } else if line.starts_with(TRACED_COMMAND) {
        LineMarker::Command
    } else {
        LineMarker::Plain
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse(line: &[u8]) -> ParsedLine<'_> {
        JenkinsLogDialect.parse(line)
    }

    #[test]
    fn stage_body_opening_starts_a_section_named_after_the_stage() {
        let cases: [(&[u8], &[u8]); 4] = [
            (b"[Pipeline] { (Build)", b"Build"),
            (
                b"[Pipeline] { (Declarative: Checkout SCM)",
                b"Declarative: Checkout SCM",
            ),
            (b"[Pipeline] { (Deploy (prod))", b"Deploy (prod)"),
            (b"[Pipeline] { (build & test)", b"build & test"),
        ];
        for (line, name) in cases {
            let parsed = parse(line);
            assert_eq!(
                parsed.marker,
                LineMarker::SectionStart { open: false },
                "{line:?}"
            );
            assert_eq!(parsed.body, name, "{line:?}");
        }
    }

    #[test]
    fn stage_step_close_ends_the_group() {
        let parsed = parse(b"[Pipeline] // stage");
        assert_eq!(parsed.marker, LineMarker::GroupEnd);
    }

    #[test]
    fn other_pipeline_lines_stay_plain() {
        let lines: [&[u8]; 12] = [
            b"[Pipeline] stage",
            b"[Pipeline] {",
            b"[Pipeline] }",
            b"[Pipeline] // node",
            b"[Pipeline] // stages",
            b"[Pipeline] echo",
            b"[Pipeline] node",
            b"[Pipeline] Start of Pipeline",
            b"[Pipeline] { (Branch: unit)",
            b"[Pipeline] { ()",
            b"[Pipeline] { (unclosed",
            b"[Pipeline] shell",
        ];
        for line in lines {
            let parsed = parse(line);
            assert_eq!(parsed.marker, LineMarker::Plain, "{line:?}");
            assert_eq!(parsed.body, line, "{line:?}");
        }
    }

    #[test]
    fn shell_step_and_traced_commands_are_commands() {
        let lines: [&[u8]; 3] = [b"[Pipeline] sh", b"+ make test", b"+ echo \"a + b\""];
        for line in lines {
            let parsed = parse(line);
            assert_eq!(parsed.marker, LineMarker::Command, "{line:?}");
            assert_eq!(parsed.body, line, "{line:?}");
        }
    }

    #[test]
    fn plus_without_a_space_is_output() {
        for line in [b"+make".as_slice(), b"++ nested", b"a + b", b"+"] {
            assert_eq!(parse(line).marker, LineMarker::Plain, "{line:?}");
        }
    }

    #[test]
    fn error_lines_and_failed_result_are_errors() {
        let lines: [&[u8]; 3] = [
            b"ERROR: script returned exit code 1",
            b"ERROR: Build step failed with exception",
            b"Finished: FAILURE",
        ];
        for line in lines {
            let parsed = parse(line);
            assert_eq!(parsed.marker, LineMarker::Error, "{line:?}");
            assert_eq!(parsed.body, line, "{line:?}");
        }
    }

    #[test]
    fn other_results_and_look_alikes_are_output() {
        let lines: [&[u8]; 6] = [
            b"Finished: SUCCESS",
            b"Finished: UNSTABLE",
            b"Finished: ABORTED",
            b"Finished: FAILURE twice",
            b"ERROR:",
            b"error: lowercase",
        ];
        for line in lines {
            assert_eq!(parse(line).marker, LineMarker::Plain, "{line:?}");
        }
    }

    #[test]
    fn freestyle_console_has_no_sections() {
        let lines: [&[u8]; 6] = [
            b"Started by user alice",
            b"Running as SYSTEM",
            b"Building in workspace /var/jenkins_home/workspace/app",
            b"[app] $ /bin/sh -xe /tmp/jenkins123.sh",
            b"Hello from the build",
            b"Finished: SUCCESS",
        ];
        for line in lines {
            let parsed = parse(line);
            assert_eq!(parsed.marker, LineMarker::Plain, "{line:?}");
            assert_eq!(parsed.body, line, "{line:?}");
            assert_eq!(parsed.timestamp, None);
        }
    }
}