logloom 0.1.1

Parse, validate, and export changelogs with a CLI and Rust library
Documentation
//! User-facing help, actionable diagnostics, and quiet machine-readable output.

use std::fmt::Write as _;
use std::fs;
use std::io::{self, Write};
use std::process::{Command, Output, Stdio};

use logloom::Changelog;

fn invoke(arguments: &[&str]) -> io::Result<Output> {
    Command::new(env!("CARGO_BIN_EXE_logloom"))
        .args(arguments)
        .output()
}

fn stdin(input: &str, arguments: &[&str]) -> io::Result<Output> {
    let mut child = Command::new(env!("CARGO_BIN_EXE_logloom"))
        .args(arguments)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()?;
    child
        .stdin
        .take()
        .ok_or_else(|| io::Error::other("stdin not piped"))?
        .write_all(input.as_bytes())?;
    child.wait_with_output()
}

#[test]
fn help_groups_options_and_keeps_all_formats_and_examples_discoverable() {
    let output = invoke(&["parse", "--help"]).unwrap();
    assert!(output.status.success());
    assert_eq!(output.stderr, Vec::<u8>::new());
    let help = String::from_utf8(output.stdout).unwrap();
    for label in [
        "Input:",
        "Output:",
        "Filters:",
        "Examples:",
        "Input formats:",
        "Output formats:",
    ] {
        assert!(help.contains(label), "{label}");
    }
    for name in [
        "auto",
        "keep-a-changelog",
        "conventional-changelog",
        "generic-markdown",
        "github-release-notes",
        "changesets",
        "towncrier",
        "debian-changelog",
        "gnu-news",
        "gnu-changelog",
        "json",
        "json-compact",
        "yaml",
        "toml",
        "jsonl",
        "csv",
        "text",
        "markdown",
    ] {
        assert!(help.contains(name), "{name}");
    }
    assert!(help.contains("logloom parse --latest --output-format text"));
    let output = invoke(&["parse", "-", "--input-format", "md"]).unwrap();
    assert_eq!(output.status.code(), Some(2));
    assert!(
        String::from_utf8(output.stderr)
            .unwrap()
            .contains("keep-a-changelog")
    );
}

#[test]
fn detection_hints_address_empty_input_json_syntax_and_ambiguity_separately() {
    let empty = stdin("", &["detect", "-"]).unwrap();
    let empty_error = String::from_utf8(empty.stderr).unwrap();
    assert!(empty_error.contains("error[empty-input]"));
    assert!(empty_error.contains("Provide a changelog file"));
    assert!(!empty_error.contains("--input-format"));

    let ambiguous = stdin("# Notes\n\n- A fix.\n", &["detect", "-"]).unwrap();
    let ambiguous_error = String::from_utf8(ambiguous.stderr).unwrap();
    assert!(ambiguous_error.contains("error[ambiguous-format]"));
    assert!(ambiguous_error.contains("'logloom parse' or 'logloom validate'"));
    assert!(!ambiguous_error.contains("16 MiB"));
    assert!(!ambiguous_error.contains("^^^^"));

    let malformed = stdin("{\n\"tag_name\":\n}", &["parse", "-"]).unwrap();
    let error = String::from_utf8(malformed.stderr).unwrap();
    assert!(error.contains("error[detect-input]"));
    assert!(error.contains("<stdin>:3:1"));
    assert!(error.contains("Fix the input syntax"));
    assert_eq!(error.matches("cannot inspect JSON").count(), 1);
}

#[test]
fn invalid_limits_and_empty_filters_have_usage_errors_without_type_jargon() {
    for value in ["0", "-1", "many", "99999999999999999999999999"] {
        let output = invoke(&["parse", "examples/keep-a-changelog.md", "--limit", value]).unwrap();
        assert_eq!(output.status.code(), Some(2));
        assert_eq!(output.stdout, Vec::<u8>::new());
        let error = String::from_utf8(output.stderr).unwrap();
        assert!(error.contains("positive whole number"));
        assert!(!error.contains("non-zero type"));
        assert!(!error.contains("use '-- -1'"));
    }
    for flag in ["--release", "--category", "--component", "--package"] {
        let output = invoke(&["parse", "examples/keep-a-changelog.md", flag, ""]).unwrap();
        assert_eq!(output.status.code(), Some(2));
        assert_eq!(output.stdout, Vec::<u8>::new());
    }
}

#[test]
fn no_matches_has_one_note_without_changing_output_or_success_status() {
    for format in ["json", "jsonl", "text"] {
        let output = invoke(&[
            "parse",
            "examples/keep-a-changelog.md",
            "--category",
            "Fiexd",
            "--output-format",
            format,
        ])
        .unwrap();
        assert!(output.status.success());
        let diagnostics = String::from_utf8(output.stderr).unwrap();
        assert_eq!(diagnostics.matches("note[no-matches]").count(), 1);
        assert!(diagnostics.contains("Check the filter values"));
        if format == "json" {
            assert_eq!(
                serde_json::from_slice::<Changelog>(&output.stdout)
                    .unwrap()
                    .releases
                    .len(),
                0
            );
        } else if format == "jsonl" {
            assert_eq!(output.stdout, Vec::<u8>::new());
        }
    }
    let matched = invoke(&[
        "parse",
        "examples/keep-a-changelog.md",
        "--category",
        "Fixed",
    ])
    .unwrap();
    assert!(matched.status.success());
    assert_eq!(matched.stderr, Vec::<u8>::new());
}

#[test]
fn repeated_warnings_are_grouped_with_every_location_and_errors_stay_individual() {
    let mut source = "# Changelog\n\n## [Unreleased]\n\n".to_owned();
    for index in 0..50 {
        writeln!(source, "### Category {index}\n\n- A fix.\n").unwrap();
    }
    let output = stdin(&source, &["validate", "-"]).unwrap();
    assert!(output.status.success());
    assert_eq!(output.stdout, Vec::<u8>::new());
    let diagnostics = String::from_utf8(output.stderr).unwrap();
    assert_eq!(diagnostics.matches("warning[custom-category]").count(), 1);
    assert!(diagnostics.contains("49 more occurrences"));
    assert!(diagnostics.contains("<stdin>:5:1"));
    let locations: Vec<usize> = diagnostics
        .lines()
        .filter_map(|line| line.split_once("also at lines ").map(|(_, rest)| rest))
        .flat_map(|line| line.split(", "))
        .map(|line| line.parse().unwrap())
        .collect();
    let expected: Vec<usize> = source
        .lines()
        .enumerate()
        .filter(|(_, line)| line.starts_with("### "))
        .map(|(index, _)| index.checked_add(1).unwrap())
        .skip(1)
        .collect();
    assert_eq!(locations, expected);
    let output = invoke(&["validate", "tests/fixtures/invalid-dates.md"]).unwrap();
    assert_eq!(output.status.code(), Some(1));
    assert_eq!(
        String::from_utf8(output.stderr)
            .unwrap()
            .matches("error[invalid-date]")
            .count(),
        4
    );
}

#[test]
fn latest_and_io_errors_point_to_the_actual_problem() {
    let output = invoke(&["parse", "examples/changesets.md", "--latest"]).unwrap();
    assert_eq!(output.status.code(), Some(1));
    let error = String::from_utf8(output.stderr).unwrap();
    assert!(error.contains("\"2.0.0\" (@lantern/core) has no date"));
    assert!(error.contains("changesets.md:3:1"));
    assert!(error.contains("Replace --latest with --first-published"));
    let directory = tempfile::tempdir().unwrap();
    let output = Command::new(env!("CARGO_BIN_EXE_logloom"))
        .arg("parse")
        .arg(directory.path())
        .output()
        .unwrap();
    assert_eq!(output.status.code(), Some(3));
    assert!(
        String::from_utf8(output.stderr)
            .unwrap()
            .contains("file instead of a directory")
    );
    let output = Command::new(env!("CARGO_BIN_EXE_logloom"))
        .args(["parse", "examples/keep-a-changelog.md", "-o"])
        .arg(directory.path().join("missing/result.json"))
        .output()
        .unwrap();
    assert_eq!(output.status.code(), Some(3));
    assert!(
        String::from_utf8(output.stderr)
            .unwrap()
            .contains("Create the parent directory first")
    );
}

#[test]
fn directories_have_actionable_errors_for_every_input_command() {
    let directory = tempfile::tempdir().unwrap();
    fs::create_dir(directory.path().join("CHANGELOG.md")).unwrap();
    for command in ["parse", "validate", "detect"] {
        for explicit_path in [false, true] {
            let mut invocation = Command::new(env!("CARGO_BIN_EXE_logloom"));
            invocation
                .current_dir(directory.path())
                .args(["--color", "never", command]);
            if explicit_path {
                invocation.arg(".");
            }
            let output = invocation.output().unwrap();
            assert_eq!(output.status.code(), Some(3), "{command}");
            assert_eq!(output.stdout, Vec::<u8>::new(), "{command}");
            let error = String::from_utf8(output.stderr).unwrap();
            assert!(error.contains("error[read-input]"), "{command}: {error}");
            assert!(
                error.contains("input path is a directory"),
                "{command}: {error}"
            );
            assert!(
                error.contains("file instead of a directory"),
                "{command}: {error}"
            );
        }
    }
}

#[test]
fn output_dash_uses_stdout_and_dot_dash_can_still_name_a_file() {
    let directory = tempfile::tempdir().unwrap();
    let input = fs::canonicalize("examples/keep-a-changelog.md").unwrap();
    let output = Command::new(env!("CARGO_BIN_EXE_logloom"))
        .current_dir(directory.path())
        .arg("parse")
        .arg(&input)
        .args(["-o", "-"])
        .output()
        .unwrap();
    assert!(output.status.success());
    assert_eq!(output.stderr, Vec::<u8>::new());
    assert!(serde_json::from_slice::<Changelog>(&output.stdout).is_ok());
    assert!(!directory.path().join("-").exists());
    let output = Command::new(env!("CARGO_BIN_EXE_logloom"))
        .current_dir(directory.path())
        .arg("parse")
        .arg(&input)
        .args(["-o", "./-"])
        .output()
        .unwrap();
    assert!(output.status.success());
    assert_eq!(output.stdout, Vec::<u8>::new());
    assert!(
        serde_json::from_slice::<Changelog>(&fs::read(directory.path().join("-")).unwrap()).is_ok()
    );
}