changelog_document 0.1.7

Feature-rich changelog format. Include a parser and serializer.
Documentation
use std::str::FromStr;

use crate::utils::UNRELEASED;

use super::*;
use pom::parser::*;
use utils::*;

#[derive(Debug, Clone)]
pub struct Options {
    pub unreleased: String,
}

impl Default for Options {
    fn default() -> Self {
        Self {
            unreleased: String::from(UNRELEASED),
        }
    }
}

pub fn parse_changelog_with_options(input: &str, options: &Options) -> anyhow::Result<ChangeLog> {
    let input = input.chars().collect::<Vec<_>>();
    let parser = changelog_parser(options);
    let changelog = parser.parse(&input)?;

    Ok(changelog)
}

pub fn parse_changelog(input: &str) -> anyhow::Result<ChangeLog> {
    parse_changelog_with_options(input, &Options::default())
}

pub(crate) fn changelog_parser(options: &Options) -> Parser<'_, char, ChangeLog> {
    let header = (!call(release) * any()).repeat(0..).convert(|header| {
        let header = into_string(header);

        if header.is_empty() {
            Ok::<_, ()>(None)
        } else {
            Ok(Some(header))
        }
    });

    let parser = header + release().repeat(0..) + footer_links();

    parser.convert(|((header, releases_vec), footer_links)| {
        let mut releases = BTreeMap::new();

        let mut unreleased = None;

        for (pos, release) in releases_vec.into_iter().enumerate() {
            if release.title.version == options.unreleased {
                if unreleased.is_some() {
                    return Err(format!("more than one {} section", options.unreleased));
                }

                if pos != 0 {
                    return Err(format!("{} section not at index 0", options.unreleased));
                }

                unreleased = Some(release);
                continue;
            }

            let version = match Version::from_str(&release.title.version) {
                Ok(v) => v,
                Err(e) => return Err(format!("not valid semver {e}")),
            };

            if releases.insert(version, release).is_some() {
                return Err("Duplicate version found".to_string());
            }
        }

        let res = ChangeLog {
            header,
            unreleased,
            releases,
            footer_links,
        };

        Ok::<ChangeLog, String>(res)
    })
}

pub(crate) fn release_title<'a>() -> Parser<'a, char, ReleaseTitle> {
    let version = sym('#').repeat(2) * sym(' ') * sym('[') * none_of("\n]").repeat(1..) - sym(']');

    let release_link = sym('(') * none_of("\n)").repeat(1..) - sym(')');

    let title = sym(' ') * sym('-') * sym(' ') * none_of("\n]").repeat(1..);

    let parser = version + release_link.opt() + title.opt();

    parser.convert(|((version, release_link), title)| {
        let res = ReleaseTitle {
            version: into_string(version),
            title: title.map(into_string),
            release_link: release_link.map(into_string),
        };

        Ok::<ReleaseTitle, ()>(res)
    })
}

pub(crate) fn release_section_note<'a>() -> Parser<'a, char, ReleaseSectionNote> {
    let scope = none_of(" \t\r`:\n").repeat(1..) - sym(':');

    let context_line = one_of(" \t") * none_of("\n").repeat(1..) - sym('\n');

    let context = context_line.repeat(0..);

    let parser = spaceline() * sym('-') * sym(' ') * scope.opt() + none_of("\n").repeat(1..)
        - sym('\n')
        + context;

    parser.convert(|((scope, note), context)| {
        let res = ReleaseSectionNote {
            scope: scope.map(into_string),
            message: into_string(note),
            context: context.into_iter().map(into_string).collect(),
        };

        Ok::<ReleaseSectionNote, ()>(res)
    })
}

pub(crate) fn release_section<'a>() -> Parser<'a, char, ReleaseSection> {
    let title = space() * sym('#').repeat(3) * sym(' ') * none_of("\n").repeat(1..) - sym('\n');

    let parser = title - space() + release_section_note().repeat(0..);

    parser.convert(|(title, notes)| {
        let res = ReleaseSection {
            title: into_string(title),
            notes,
        };

        Ok::<ReleaseSection, ()>(res)
    })
}

pub(crate) fn release<'a>() -> Parser<'a, char, Release> {
    let header = ((!call(release_title) + !call(release_section) + !call(footer_links)) * any())
        .repeat(0..)
        .convert(|header| {
            let header = into_string(header);

            if header.is_empty() {
                Ok::<_, ()>(None)
            } else {
                Ok(Some(header))
            }
        });

    let footer = ((!call(release_title) + !call(release_section) + !call(footer_links)) * any())
        .repeat(0..)
        .convert(|footer| {
            let footer = into_string(footer);

            if footer.is_empty() {
                Ok::<_, ()>(None)
            } else {
                Ok(Some(footer))
            }
        });

    let parser = release_title() + header + release_section().repeat(0..) + footer;

    parser.convert(|(((title, header), sections), footer)| {
        let mut notes = IndexMap::new();

        for section in sections.into_iter() {
            notes.insert(section.title.clone(), section);
        }

        let res = Release {
            title,
            header,
            note_sections: notes,
            footer,
        };

        Ok::<Release, ()>(res)
    })
}

pub(crate) fn footer_link<'a>() -> Parser<'a, char, FooterLink> {
    let parser = sym('[') * none_of("\n]").repeat(1..) - sym(']') * sym(':') * sym(' ')
        + none_of("\n").repeat(1..)
        - sym('\n');

    parser.convert(|(text, link)| {
        let res = FooterLink {
            text: into_string(text),
            link: into_string(link),
        };

        Ok::<FooterLink, ()>(res)
    })
}

pub(crate) fn footer_links<'a>() -> Parser<'a, char, FooterLinks> {
    let parser = space() * footer_link().repeat(0..) - space() - end();

    parser.convert(|links| {
        let res = FooterLinks { links };

        Ok::<FooterLinks, ()>(res)
    })
}

pub(crate) mod utils {
    use pom::parser::*;

    pub fn into_string(v: Vec<char>) -> String {
        let str = v.into_iter().collect::<String>();
        let str = str.trim();
        str.to_owned()
    }

    pub fn space<'a>() -> Parser<'a, char, ()> {
        one_of(" \t\r\n").repeat(0..).discard()
    }
    pub fn spaceline<'a>() -> Parser<'a, char, ()> {
        one_of(" \n").repeat(0..).discard()
    }
}