git-tags-semver 0.6.2

Tool to extract SemVer Version Information from annotated git tags
Documentation
// Copyright Open Logistics Foundation
//
// Licensed under the Open Logistics Foundation License 1.3.
// For details on the licensing terms, see the LICENSE file.
// SPDX-License-Identifier: OLFL-1.3

//! This module is responsible for parsing and converting the output of the `git describe` command
//! into a `GitVersion` object.

use std::convert::TryFrom;

use crate::{GitVersion, SemanticVersion, VersionError, VersionString};
use regex::Regex;

pub fn run_git_describe() -> Result<String, VersionError> {
    let git_string = std::process::Command::new("git")
        .args(&[
            "describe",
            "--always",
            "--dirty=-dirty",
            "--long",
            "--match=*v[0-9]*.[0-9]*.[0-9]*",
            "--abbrev=8",
        ])
        .output()
        .map_err(|_| VersionError::Command)?
        .stdout;
    Ok(std::str::from_utf8(&git_string)
        .map_err(|_| VersionError::UTF8)?
        .trim()
        .to_string())
}

pub fn parse_git_describe(git_string: &str) -> Result<GitVersion, VersionError> {
    // Use some regex magic to parse that info to a struct
    // Thanks to the --long option, there is only three main formats to match
    // (release, rc, no tag) each could be followed by a -dirty suffix.
    // Possible formats
    // v1.0.0-0-g01234567      exact release version
    // v1.0.0-rc-5-gabcdefed   behind rc version
    // 02468ace                no release made yet
    // <format>-dirty          dirty flag (applies to all of the above)

    let parser = Regex::new(
        r"(?x)                  # insignificant whitespace mode (for comments)
        ^                       # line start
        (
          (?:(?P<prefix>[\w\d]+)-)? # Start the version with an optional prefix that ends with a hyphen
          v                      # the whole semver block starts with v 
          (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)  # semver numbers
          (?P<rc>-rc)?          # '-rc' is optional
          -(?P<commits>\d+)     # '-<n>' mandatory commit-counter
          -g                    # '-g' precedes the hash if a tag was found
        )?                      # semver block is optional (missing if no
                                # tag / release was made yet)
        (?P<hash>[\w\d]{8})     # 8 alphanumeric digits for the hash
        (?P<dirty>-dirty)?      # optional '-dirty' flag
        $                       # line end (therefore, call trim() before)
        ",
    )
    .map_err(|_| VersionError::Regex)?;

    let captures = parser
        .captures(git_string)
        .ok_or_else(|| VersionError::NoGitHash(VersionString::try_from(git_string).unwrap()))?;

    // First, check if the semver information was found and generate code,
    // otherwise "None"
    let semver = match (
        captures.name("major"),
        captures.name("minor"),
        captures.name("patch"),
        captures.name("commits"),
    ) {
        (Some(major), Some(minor), Some(patch), Some(commits)) => {
            let rc = captures.name("rc").is_some();
            Some(SemanticVersion {
                major: major
                    .as_str()
                    .parse::<u32>()
                    .map_err(|_| VersionError::InvalidSemver)?,
                minor: minor
                    .as_str()
                    .parse::<u32>()
                    .map_err(|_| VersionError::InvalidSemver)?,
                patch: patch
                    .as_str()
                    .parse::<u32>()
                    .map_err(|_| VersionError::InvalidSemver)?,
                rc,
                commits: commits
                    .as_str()
                    .parse::<u32>()
                    .map_err(|_| VersionError::InvalidSemver)?,
            })
        }
        (None, None, None, None) => None,
        _ => {
            return Err(VersionError::IncompleteSemver);
        }
    };

    // Then build the GitVersion which contains the semver info
    let hash_str = captures
        .name("hash")
        .expect("The git hash should be matched in any case.")
        .as_str();
    let hash = u32::from_str_radix(hash_str, 16)
        .expect("The git hash is not hexadecimal")
        .to_be_bytes();
    let dirty = captures.name("dirty").is_some();
    let version_string = heapless::String::try_from(git_string).unwrap();

    Ok(GitVersion {
        semver,
        hash,
        dirty,
        git_string: version_string,
    })
}

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

    fn make_ver(
        semver: Option<SemanticVersion>,
        hash: [u8; 4],
        dirty: bool,
        git_string: &str,
    ) -> GitVersion {
        let version_string = heapless::String::try_from(git_string).unwrap();
        GitVersion {
            semver,
            hash,
            dirty,
            git_string: version_string,
        }
    }

    #[test]
    fn parser() -> Result<(), crate::types::VersionError> {
        //let git_string = "v0.1.0-0-g01234567";
        //let git_string = "v0.1.0-0-g01234567-dirty";
        //let git_string = "v0.1.0-rc-0-g01234567";
        //let git_string = "v0.1.0-rc-0-g01234567-dirty";
        //let git_string = "01234567";
        //let git_string = "01234567-dirty";

        assert_eq!(
            super::parse_git_describe("v0.1.0-0-g01234567")?,
            make_ver(
                Some(SemanticVersion {
                    major: 0,
                    minor: 1,
                    patch: 0,
                    rc: false,
                    commits: 0,
                }),
                [0x01, 0x23, 0x45, 0x67],
                false,
                "v0.1.0-0-g01234567"
            )
        );

        assert_eq!(
            super::parse_git_describe("v0.1.0-0-g01234567-dirty")?,
            make_ver(
                Some(SemanticVersion {
                    major: 0,
                    minor: 1,
                    patch: 0,
                    rc: false,
                    commits: 0,
                }),
                [0x01, 0x23, 0x45, 0x67],
                true,
                "v0.1.0-0-g01234567-dirty"
            )
        );

        assert_eq!(
            super::parse_git_describe("v0.1.0-rc-0-g01234567")?,
            make_ver(
                Some(SemanticVersion {
                    major: 0,
                    minor: 1,
                    patch: 0,
                    rc: true,
                    commits: 0,
                }),
                [0x01, 0x23, 0x45, 0x67],
                false,
                "v0.1.0-rc-0-g01234567"
            )
        );

        Ok(())
    }

    #[test]
    fn test_parser_with_prefix() {
        assert_eq!(
            super::parse_git_describe("some711-v0.1.0-rc-0-g01234567").unwrap(),
            make_ver(
                Some(SemanticVersion {
                    major: 0,
                    minor: 1,
                    patch: 0,
                    rc: true,
                    commits: 0,
                }),
                [0x01, 0x23, 0x45, 0x67],
                false,
                "some711-v0.1.0-rc-0-g01234567"
            )
        );
    }

    #[test]
    fn test_git_describe_command() {
        let version = super::run_git_describe().unwrap();
        let git_version = super::parse_git_describe(&version).unwrap();
        assert!(git_version.semver.is_some());
        assert_eq!(git_version.hash.len(), 4);
        assert!(git_version.git_string.len() >= 13);
    }
}