logloom 0.1.1

Parse, validate, and export changelogs with a CLI and Rust library
Documentation
use std::collections::HashSet;

use crate::{Changelog, ErrorKind, Format, MAX_INPUT_BYTES, ParseError};

use super::{histories, markdown, source::Source, text};

pub fn parse(input: &str) -> Result<Changelog, ParseError> {
    if input
        .lines()
        .next()
        .is_some_and(|line| line.trim() == "---")
    {
        fragment(input)
    } else {
        histories::parse(input, Format::Changesets)
    }
}

fn fragment(input: &str) -> Result<Changelog, ParseError> {
    let source = Source::new(input);
    let mut lines = source.lines();
    lines.next();
    let mut packages = Vec::new();
    let mut seen = HashSet::new();
    let mut body_start = None;
    for (span, line) in lines {
        if line.trim() == "---" {
            body_start = Some(span.end);
            break;
        }
        if line.trim().is_empty() || line.trim_start().starts_with('#') {
            continue;
        }
        let (package, bump) = package_line(line).ok_or_else(|| {
            ParseError::new(
                ErrorKind::MalformedInput,
                Some(span.line),
                "expected a flat YAML package-to-major/minor/patch mapping",
            )
        })?;
        if !seen.insert(package.clone()) {
            return Err(ParseError::new(
                ErrorKind::MalformedInput,
                Some(span.line),
                "duplicate package in changeset frontmatter",
            ));
        }
        packages.push((package, bump, span));
    }
    let body_start = body_start.ok_or_else(|| {
        ParseError::new(
            ErrorKind::MalformedInput,
            Some(1),
            "changeset YAML frontmatter has no closing '---'",
        )
    })?;
    let body = source.text(body_start..input.len())?;
    let expanded = body.len().checked_mul(packages.len().max(1));
    if expanded.is_none_or(|size| size > MAX_INPUT_BYTES) {
        return Err(ParseError::new(
            ErrorKind::ResourceLimit,
            Some(1),
            "expanded changeset summaries exceed the 16 MiB limit",
        ));
    }
    let mut changelog = markdown::body(body)?;
    text::rebase(&mut changelog, &source, body_start)?;
    source
        .text(0..body_start)?
        .trim()
        .clone_into(&mut changelog.metadata.description);
    let template = changelog.releases.pop().ok_or_else(|| {
        ParseError::new(
            ErrorKind::MalformedInput,
            None,
            "missing changeset summary container",
        )
    })?;
    if packages.is_empty() {
        packages.push((String::new(), String::new(), source.span(0..body_start)));
    }
    for (package, bump, span) in packages {
        let mut release = template.clone();
        release.attributes.remove("version-status");
        release
            .attributes
            .insert("fragment".to_owned(), "changesets".to_owned());
        if !package.is_empty() {
            release.attributes.insert("package".to_owned(), package);
            release.attributes.insert("bump".to_owned(), bump);
        }
        source
            .text(text::range(span))?
            .trim()
            .clone_into(&mut release.heading);
        release.span = span;
        changelog.releases.push(release);
    }
    Ok(changelog)
}

fn package_line(line: &str) -> Option<(String, String)> {
    let (key, value) = line.rsplit_once(':')?;
    let key = scalar(key.trim())?;
    if key.is_empty()
        || key.contains(char::is_whitespace)
        || key.starts_with(['&', '*', '{', '[', '!', '#'])
    {
        return None;
    }
    let value = value
        .split_once(" #")
        .map_or(value, |(value, _)| value)
        .trim();
    let bump = scalar(value)?;
    if !matches!(bump.as_str(), "major" | "minor" | "patch") {
        return None;
    }
    Some((key, bump))
}

fn scalar(value: &str) -> Option<String> {
    if value.starts_with('"') {
        serde_json::from_str(value).ok()
    } else if value.starts_with('\'') {
        Some(
            value
                .strip_prefix('\'')?
                .strip_suffix('\'')?
                .replace("''", "'"),
        )
    } else if value.contains(['\'', '"', '{', '}', '[', ']']) {
        None
    } else {
        Some(value.to_owned())
    }
}