clparser 0.1.2

A command line tool for parsing CHANGELOG.md files that use the Keep A Changelog format. (fork marcaddeo/clparse)
Documentation
use anyhow::Result;
use changelog::{Change, Changelog, ChangelogBuilder, Release, ReleaseBuilder};
use chrono::NaiveDate;
use err_derive::Error;
use pulldown_cmark::{Event, HeadingLevel, LinkType, Parser, Tag};
use versions::Version;
use std::fs::File;
use std::io::prelude::*;
use std::path::PathBuf;

pub mod changelog;

#[derive(Clone, Debug)]
enum ChangelogFormat {
    Markdown,
    Json,
    Yaml,
}

#[derive(Clone, Debug)]
enum ChangelogSection {
    None,
    Title,
    Description,
    ReleaseHeader,
    ChangesetHeader,
    Changeset(String),
}

#[derive(Debug, Error)]
pub enum ChangelogParserError {
    #[error(display = "unable to determine file format from contents")]
    UnableToDetermineFormat,
    #[error(display = "error building release")]
    ErrorBuildingRelease(String),
}

pub struct ChangelogParser {
    separator: String,
    wrap: Option<usize>,
}

impl ChangelogParser {
    pub fn new(separator: String, wrap: Option<usize>) -> Self {
        Self {
            separator,
            wrap,
        }
    }

    pub fn parse(&self, path: PathBuf) -> Result<Changelog> {
        let mut document = String::new();
        File::open(path.clone())?.read_to_string(&mut document)?;
        self.parse_buffer(document)
    }

    pub fn parse_buffer(&self, buffer: String) -> Result<Changelog> {
        match Self::get_format_from_buffer(buffer.clone()) {
            Ok(format) => match format {
                ChangelogFormat::Markdown => self.parse_markdown(buffer),
                ChangelogFormat::Json => Self::parse_json(buffer),
                ChangelogFormat::Yaml => Self::parse_yaml(buffer),
            },
            _ => Err(ChangelogParserError::UnableToDetermineFormat.into()),
        }
    }

    fn parse_markdown(&self, markdown: String) -> Result<Changelog> {
        let parser = Parser::new(&markdown);

        let mut section = ChangelogSection::None;

        let mut title = String::new();
        let mut description = String::new();
        let mut description_links = String::new();
        let mut releases: Vec<Release> = Vec::new();

        let mut release = ReleaseBuilder::default();
        let mut changeset: Vec<Change> = Vec::new();
        let mut accumulator = String::new();
        let mut link_accumulator = String::new();

        for event in parser {
            match event {
                // Headings.
                Event::Start(Tag::Heading(HeadingLevel::H1, ..)) => section = ChangelogSection::Title,
                Event::End(Tag::Heading(HeadingLevel::H1, ..)) => section = ChangelogSection::Description,
                Event::Start(Tag::Heading(HeadingLevel::H2, ..)) => {
                    match section {
                        ChangelogSection::Description => {
                            description = accumulator.clone();
                            accumulator = String::new();
                        }
                        ChangelogSection::Changeset(_) | ChangelogSection::ReleaseHeader => {
                            self.parse_release_header(&mut release, &mut accumulator);
                            self.build_release(&mut releases, &mut release, &mut changeset)?;
                        }
                        _ => (),
                    }

                    section = ChangelogSection::ReleaseHeader;
                }
                Event::Start(Tag::Heading(HeadingLevel::H3, ..)) => section = ChangelogSection::ChangesetHeader,

                // Links.
                Event::Start(Tag::Link(LinkType::Inline, _, _)) => accumulator.push_str("["),
                Event::Start(Tag::Link(LinkType::Collapsed, _, _)) => {
                    accumulator.push_str("[");
                    link_accumulator = String::from("[");
                }
                Event::End(Tag::Link(LinkType::Inline, href, _)) => {
                    accumulator.push_str(&format!("]({})", href));
                }
                Event::End(Tag::Link(LinkType::Collapsed, href, _)) => {
                    accumulator.push_str("][]");
                    link_accumulator.push_str(&format!("]: {}\n", href));
                    description_links.push_str(&link_accumulator);
                    link_accumulator = String::new();
                }
                Event::Start(Tag::Link(LinkType::Shortcut, href, _)) => {
                    release.link(href.to_string());
                }

                // Items.
                Event::End(Tag::Item) => {
                    if let ChangelogSection::Changeset(name) = section.clone() {
                        changeset.push(Change::new(&name, accumulator)?);

                        accumulator = String::new();
                    }
                }

                // Line breaks.
                Event::SoftBreak => accumulator.push_str("\n"),
                Event::End(Tag::Paragraph) => accumulator.push_str("\n\n"),

                // Inline code.
                Event::Code(text) => accumulator.push_str(&format!("`{}`", text)),

                // Text formatting.
                Event::Start(Tag::Strong) | Event::End(Tag::Strong) => accumulator.push_str("**"),
                Event::Start(Tag::Emphasis) | Event::End(Tag::Emphasis) => {
                    accumulator.push_str("_")
                }
                Event::Start(Tag::Strikethrough) | Event::End(Tag::Strikethrough) => {
                    accumulator.push_str("~~")
                }

                // Text.
                Event::Text(text) => match section {
                    ChangelogSection::Title => title = text.to_string(),
                    ChangelogSection::Description => {
                        accumulator.push_str(&text);

                        if !link_accumulator.is_empty() {
                            link_accumulator.push_str(&text);
                        }
                    }
                    ChangelogSection::ChangesetHeader => {
                        self.parse_release_header(&mut release, &mut accumulator);

                        section = ChangelogSection::Changeset(text.to_string())
                    }
                    ChangelogSection::Changeset(_) | ChangelogSection::ReleaseHeader => accumulator.push_str(&text),
                    _ => (),
                },
                _ => (),
            };
        }

        self.build_release(&mut releases, &mut release, &mut changeset)?;

        if !description_links.is_empty() {
            description = format!("{}{}\n", description, description_links);
        }

        let changelog = ChangelogBuilder::default()
            .title(title)
            .description(description)
            .releases(releases)
            .build()
            .map_err(ChangelogParserError::ErrorBuildingRelease)?;

        Ok(changelog)
    }

    fn parse_release_header(&self, release: &mut ReleaseBuilder, accumulator: &mut String) {
        let delimiter = format!(" {} ", self.separator);
        if let Some((left, right)) = accumulator.trim().split_once(&delimiter) {
            if right.contains("YANKED") {
                release.yanked(true);
            }

            let right = &right.replace(" [YANKED]", "");
            if let Ok(date) = NaiveDate::parse_from_str(&right, "%Y-%m-%d") {
                release.date(date);
            }

            let version = left.trim_matches(|c| c == '[' || c == ']');
            if let Some(version) = Version::new(&version) {
                release.version(version);
            }
        }

        *accumulator = String::new();
    }

    fn build_release(&self, releases: &mut Vec<Release>, release: &mut ReleaseBuilder, changeset: &mut Vec<Change>) -> Result<()> {
        release.changes(changeset.clone());
        release.separator(self.separator.clone());
        release.wrap(self.wrap);
        releases.push(
            release
                .build()
                .map_err(ChangelogParserError::ErrorBuildingRelease)?
        );

        *changeset = Vec::new();
        *release = ReleaseBuilder::default();

        Ok(())
    }

    fn parse_json(json: String) -> Result<Changelog> {
        let changelog: Changelog = serde_json::from_str(&json)?;

        Ok(changelog)
    }

    fn parse_yaml(yaml: String) -> Result<Changelog> {
        let changelog: Changelog = serde_yaml::from_str(&yaml)?;

        Ok(changelog)
    }

    fn get_format_from_buffer(buffer: String) -> Result<ChangelogFormat> {
        let first_char = match buffer.chars().next() {
            Some(first_char) => first_char,
            _ => {
                return Err(ChangelogParserError::UnableToDetermineFormat.into());
            }
        };

        let first_line: String = buffer.chars().take_while(|&c| c != '\n').collect();
        let mut format: Option<ChangelogFormat> = match first_char {
            '{' => Some(ChangelogFormat::Json),
            '#' => Some(ChangelogFormat::Markdown),
            _ => None,
        };

        if format.is_none() && (first_line == "---" || first_line.contains("title:")) {
            format = Some(ChangelogFormat::Yaml);
        }

        if let Some(format) = format {
            Ok(format)
        } else {
            Err(ChangelogParserError::UnableToDetermineFormat.into())
        }
    }
}