logloom 1.0.0

Parse, validate, and export changelogs with a CLI and Rust library
Documentation
use std::error::Error;
use std::io::{self, Write};

use clap::ValueEnum;
use logloom::{Changelog, Format, LinkDefinition, Metadata, Release, ReleaseStatus};
use serde::Serialize;

mod readable;
#[cfg(feature = "csv")]
mod table;

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum OutputFormat {
    /// Pretty-printed JSON without the original source copy.
    Json,
    /// Single-line JSON without the original source copy.
    JsonCompact,
    /// Readable text with Markdown styling removed.
    Text,
    /// YAML without the original source copy.
    #[cfg(feature = "yaml")]
    Yaml,
    /// Markdown of the selected releases.
    Markdown,
    /// TOML without the source copy or absent optional fields.
    #[cfg(feature = "toml")]
    Toml,
    /// One compact release object per line.
    Jsonl,
    /// A header and one row per entry, including empty groups.
    #[cfg(feature = "csv")]
    Csv,
}

impl OutputFormat {
    pub const fn name(self) -> &'static str {
        match self {
            Self::Json => "json",
            Self::JsonCompact => "json-compact",
            Self::Text => "text",
            #[cfg(feature = "yaml")]
            Self::Yaml => "yaml",
            Self::Markdown => "markdown",
            #[cfg(feature = "toml")]
            Self::Toml => "toml",
            Self::Jsonl => "jsonl",
            #[cfg(feature = "csv")]
            Self::Csv => "csv",
        }
    }
}

// Borrow the export fields so diagnostics and readable renderers can still use
// the intact source without cloning the model or serializing it into a Value.
#[derive(Serialize)]
struct Document<'a> {
    format: Format,
    metadata: &'a Metadata,
    releases: &'a [Release],
    links: &'a [LinkDefinition],
}

pub fn render(changelog: &Changelog, format: OutputFormat) -> Result<Vec<u8>, Box<dyn Error>> {
    let mut buffer = Buffer::default();
    let document = Document {
        format: changelog.format,
        metadata: &changelog.metadata,
        releases: &changelog.releases,
        links: &changelog.links,
    };
    match format {
        OutputFormat::Json => serde_json::to_writer_pretty(&mut buffer, &document)?,
        OutputFormat::JsonCompact => serde_json::to_writer(&mut buffer, &document)?,
        #[cfg(feature = "yaml")]
        OutputFormat::Yaml => yaml_serde::to_writer(&mut buffer, &document)?,
        #[cfg(feature = "toml")]
        OutputFormat::Toml => buffer.write_all(toml::to_string_pretty(&document)?.as_bytes())?,
        OutputFormat::Jsonl => {
            for release in &changelog.releases {
                serde_json::to_writer(&mut buffer, release)?;
                buffer.write_all(b"\n")?;
            }
        }
        #[cfg(feature = "csv")]
        OutputFormat::Csv => table::render(changelog, &mut buffer)?,
        OutputFormat::Text => readable::text(changelog, &mut buffer)?,
        OutputFormat::Markdown => readable::markdown(changelog, &mut buffer)?,
    }
    if !buffer.bytes.is_empty() && !buffer.bytes.ends_with(b"\n") {
        buffer.write_all(b"\n")?;
    }
    Ok(buffer.bytes)
}

// Repeated CSV metadata and resolved text can amplify a small input. Bound the
// buffered export before opening the destination, just as we bound parser input.
const MAX_OUTPUT_BYTES: usize = 256 * 1024 * 1024;

struct Buffer {
    bytes: Vec<u8>,
    limit: usize,
}

impl Default for Buffer {
    fn default() -> Self {
        Self {
            bytes: Vec::new(),
            limit: MAX_OUTPUT_BYTES,
        }
    }
}

impl Write for Buffer {
    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
        if self
            .bytes
            .len()
            .checked_add(bytes.len())
            .is_none_or(|size| size > self.limit)
        {
            return Err(io::Error::other(
                "rendered output exceeds the 256 MiB limit; select fewer releases or sections",
            ));
        }
        self.bytes.extend_from_slice(bytes);
        Ok(bytes.len())
    }

    fn flush(&mut self) -> io::Result<()> {
        Ok(())
    }
}

fn release_label(release: &Release) -> &str {
    release
        .version
        .as_deref()
        .unwrap_or_else(|| match release.status() {
            ReleaseStatus::Unversioned => "Unversioned",
            ReleaseStatus::DatedRecord => "Changes",
            _ => "Unreleased",
        })
}

#[cfg(all(test, feature = "csv"))]
mod tests {
    use logloom::{Format, parse};

    use super::{Buffer, table};

    #[test]
    fn csv_metadata_amplification_is_bounded_before_external_output() {
        let input = format!(
            "# Changelog\n\n## [{}] - 2026-10-01\n\n### Added\n\n{}",
            "v".repeat(500),
            "- A change.\n".repeat(30)
        );
        let model = parse(&input, Format::KeepAChangelog).unwrap();
        let mut buffer = Buffer {
            limit: 4096,
            ..Buffer::default()
        };
        assert!(input.len() < buffer.limit);
        let error = table::render(&model, &mut buffer).unwrap_err();
        assert!(error.to_string().contains("rendered output exceeds"));
        assert!(buffer.bytes.len() <= buffer.limit);
    }
}