logloom 0.1.2

Parse, validate, and export changelogs with a CLI and Rust library
Documentation
use crate::{Category, Changelog, ErrorKind, Format, ParseError, Section};

use super::{document, histories, release, source::Source, text::Entries};

pub fn parse(input: &str, format: Format) -> Result<Changelog, ParseError> {
    if format == Format::GnuChangeLog {
        change_log(input)
    } else {
        news(input)
    }
}

fn news(input: &str) -> Result<Changelog, ParseError> {
    let source = Source::new(input);
    let mut changelog = document(input);
    let mut entries = Entries::default();
    let mut first = None;
    for (span, line) in source.lines() {
        let title = line.trim_start_matches('*').trim();
        let version = [
            "Noteworthy changes in release ",
            "Changes in version ",
            "Changes in release ",
            "Version ",
        ]
        .iter()
        .find_map(|prefix| title.strip_prefix(prefix));
        if let Some(version) = version {
            if let Some(current) = changelog.releases.last_mut() {
                entries.finish(&source, current, span.start)?;
            }
            let label = news_label(version).ok_or_else(|| {
                ParseError::new(
                    ErrorKind::MalformedHeading,
                    Some(span.line),
                    "invalid GNU NEWS release heading",
                )
            })?;
            let mut current = release(label.version, line.to_owned(), span);
            current.date = label.date;
            first.get_or_insert(span.start);
            changelog.releases.push(current);
        } else if line.starts_with("** ") {
            if let Some(current) = changelog.releases.last_mut() {
                entries.finish(&source, current, span.start)?;
                let category = match title.to_ascii_lowercase().as_str() {
                    "bug fixes" => Category::Fixed,
                    "new features" => Category::Added,
                    _ => Category::from_name(title),
                };
                current.sections.push(Section {
                    component: None,
                    category: Some(category),
                    heading: Some(line.to_owned()),
                    entries: Vec::new(),
                    span,
                });
            }
        } else if let Some(current) = changelog.releases.last_mut() {
            entries.line(
                &source,
                current,
                span,
                line,
                line.trim_start().starts_with("- ") || line.trim_start().starts_with("* "),
            )?;
        }
    }
    if let Some(current) = changelog.releases.last_mut() {
        entries.finish(&source, current, input.len())?;
    }
    let first = first.ok_or_else(|| {
        ParseError::new(
            ErrorKind::NoReleases,
            None,
            "no GNU NEWS release headings found",
        )
    })?;
    source
        .text(0..first)?
        .trim()
        .clone_into(&mut changelog.metadata.description);
    changelog.metadata.title = input
        .lines()
        .next()
        .filter(|line| line.starts_with("GNU "))
        .map(str::to_owned);
    Ok(changelog)
}

fn news_label(text: &str) -> Option<histories::Label> {
    let text = if let Some((name, rest)) = text.split_once(" (") {
        let (date, _) = rest.split_once(')')?;
        format!("{name} ({date})")
    } else {
        text.to_owned()
    };
    let mut label = histories::label(&text, Format::GenericMarkdown)?;
    if label
        .version
        .as_ref()
        .is_some_and(|version| version.contains('?'))
    {
        label.version = None;
    }
    if label.date.as_ref().is_some_and(|date| date.contains('?')) {
        label.date = None;
    }
    Some(label)
}

fn change_log(input: &str) -> Result<Changelog, ParseError> {
    let source = Source::new(input);
    let mut changelog = document(input);
    let mut entries = Entries::default();
    let mut first = None;
    for (span, line) in source.lines() {
        let header = line
            .get(0..10)
            .zip(line.get(10..))
            .filter(|(date, rest)| date_shape(date) && rest.starts_with(char::is_whitespace));
        if let Some((date, author)) = header {
            if author.trim().is_empty() {
                return Err(ParseError::new(
                    ErrorKind::MalformedHeading,
                    Some(span.line),
                    "GNU ChangeLog date header is missing an author",
                ));
            }
            if let Some(current) = changelog.releases.last_mut() {
                entries.finish(&source, current, span.start)?;
            }
            let mut current = release(None, line.to_owned(), span);
            current.date = Some(date.to_owned());
            current
                .attributes
                .insert("record-kind".to_owned(), "dated-change".to_owned());
            current
                .attributes
                .insert("author".to_owned(), author.trim().to_owned());
            first.get_or_insert(span.start);
            changelog.releases.push(current);
        } else if let Some(current) = changelog.releases.last_mut() {
            entries.line(
                &source,
                current,
                span,
                line,
                line.trim_start().starts_with("* "),
            )?;
        }
    }
    if let Some(current) = changelog.releases.last_mut() {
        entries.finish(&source, current, input.len())?;
    }
    let first = first.ok_or_else(|| {
        ParseError::new(
            ErrorKind::NoReleases,
            None,
            "no ISO date-and-author GNU ChangeLog records found",
        )
    })?;
    source
        .text(0..first)?
        .trim()
        .clone_into(&mut changelog.metadata.description);
    Ok(changelog)
}

fn date_shape(text: &str) -> bool {
    let mut parts = text.split('-');
    matches!((parts.next(), parts.next(), parts.next(), parts.next()), (Some(year), Some(month), Some(day), None) if year.len()==4 && month.len()==2 && day.len()==2 && [year,month,day].iter().all(|part| part.bytes().all(|byte| byte.is_ascii_digit())))
}