verctl 0.4.5

Stack-agnostic version PRs from Changesets-format fragments
//! Parsing and loading of Changesets-format fragments, including their front
//! matter and groups.

use std::fs;

use indoc::indoc;
use tempfile::TempDir;
use verctl::fragment::{Bump, Group, load_dir, parse_str};

fn err_text(result: anyhow::Result<impl std::fmt::Debug>) -> String {
    match result {
        Ok(value) => panic!("expected error, got {value:?}"),
        Err(error) => format!("{error:#}"),
    }
}

#[test]
fn parses_quoted_and_unquoted_names() {
    let fragment = parse_str(
        indoc! {r#"
            ---
            forkctl: patch
            "@scope/pkg": minor
            ---

            Restore mise.toml when later patches unapply.
        "#},
        "mixed.md",
    )
    .expect("parse");
    assert_eq!(fragment.packages.len(), 2);
    let forkctl = fragment
        .packages
        .iter()
        .find(|package| package.name == "forkctl")
        .expect("forkctl");
    let scoped = fragment
        .packages
        .iter()
        .find(|package| package.name == "@scope/pkg")
        .expect("scoped");
    assert_eq!(forkctl.bump, Bump::Patch);
    assert_eq!(scoped.bump, Bump::Minor);
    assert_eq!(fragment.max_bump(), Bump::Minor);
    assert_eq!(
        fragment.summary,
        "Restore mise.toml when later patches unapply."
    );
}

#[test]
fn accepts_none_and_major() {
    let fragment = parse_str(
        indoc! {"
            ---
            docs: none
            api: major
            ---

            Break the flag.
        "},
        "bumps.md",
    )
    .expect("parse");
    let docs = fragment
        .packages
        .iter()
        .find(|package| package.name == "docs")
        .expect("docs");
    let api = fragment
        .packages
        .iter()
        .find(|package| package.name == "api")
        .expect("api");
    assert_eq!(docs.bump, Bump::None);
    assert_eq!(api.bump, Bump::Major);
    assert_eq!(fragment.max_bump(), Bump::Major);
}

#[test]
fn rejects_unknown_bump() {
    let error = parse_str(
        indoc! {"
            ---
            forkctl: huge
            ---

            Nope.
        "},
        "bad.md",
    )
    .expect_err("unknown bump");
    assert!(format!("{error:#}").contains("unknown bump"), "{error:#}");
}

#[test]
fn rejects_missing_fence() {
    let error = parse_str("just a note\n", "nofence.md").expect_err("fence");
    assert!(
        format!("{error:#}").contains("must start with ---"),
        "{error:#}"
    );
}

#[test]
fn rejects_empty_mapping() {
    let error = parse_str(
        indoc! {"
            ---
            {}
            ---

            Empty.
        "},
        "empty.md",
    )
    .expect_err("empty");
    assert!(format!("{error:#}").contains("no packages"), "{error:#}");
}

#[test]
fn accepts_bom_and_crlf() {
    let raw = "\u{feff}---\r\nforkctl: patch\r\n---\r\n\r\nWindows file.\r\n";
    let fragment = parse_str(raw, "crlf.md").expect("parse");
    assert_eq!(fragment.packages[0].name, "forkctl");
    assert_eq!(fragment.summary, "Windows file.");
}

#[test]
fn rejects_numeric_bump() {
    let error = err_text(parse_str(
        indoc! {"
            ---
            forkctl: 1
            ---

            Number.
        "},
        "num.md",
    ));
    assert!(error.contains("unknown bump type \"1\""), "{error}");
}

#[test]
fn rejects_sequence_front_matter() {
    let error = err_text(parse_str(
        indoc! {"
            ---
            - forkctl
            ---

            List.
        "},
        "list.md",
    ));
    assert!(error.contains("list.md:2:1: expected mapping"), "{error}");
}

#[test]
fn body_may_contain_horizontal_rule() {
    let fragment = parse_str(
        indoc! {"
            ---
            forkctl: patch
            ---

            First paragraph.

            ---

            Still the body.
        "},
        "rule.md",
    )
    .expect("parse");
    assert!(fragment.summary.contains("Still the body."));
}

#[test]
fn load_dir_skips_readme_and_missing_dir() {
    let root = TempDir::new().expect("tempdir");
    assert_eq!(
        load_dir(&root.path().join("missing")).expect("missing"),
        Vec::<verctl::fragment::Fragment>::new()
    );
    let dir = root.path().join(".changeset");
    fs::create_dir_all(&dir).expect("dir");
    fs::write(dir.join("README.md"), "not a fragment\n").expect("readme");
    fs::write(dir.join("config.json"), "{}\n").expect("config");
    fs::write(
        dir.join("ok.md"),
        indoc! {"
            ---
            forkctl: patch
            ---

            Ok.
        "},
    )
    .expect("ok");
    let loaded = load_dir(&dir).expect("load");
    assert_eq!(loaded.len(), 1);
    assert_eq!(loaded[0].packages[0].name, "forkctl");
}

#[test]
fn load_dir_fails_closed_on_one_bad_file() {
    let root = TempDir::new().expect("tempdir");
    let dir = root.path().join(".changeset");
    fs::create_dir_all(&dir).expect("dir");
    fs::write(
        dir.join("ok.md"),
        indoc! {"
            ---
            forkctl: patch
            ---

            Ok.
        "},
    )
    .expect("ok");
    fs::write(dir.join("bad.md"), "no fence\n").expect("bad");
    let error = err_text(load_dir(&dir).map(|_| ()));
    assert!(error.contains("must start with ---"), "{error}");
}

#[test]
fn a_group_key_names_the_heading_and_no_package() {
    let fragment = parse_str(
        indoc! {r#"
            ---
            demo: patch
            "verctl:group": fixed
            ---

            Fix the parser.
        "#},
        "grouped.md",
    )
    .expect("parse");
    assert_eq!(fragment.group, Some(Group::Fixed));
    assert_eq!(fragment.packages.len(), 1);
    assert_eq!(fragment.packages[0].name, "demo");
}

#[test]
fn a_fragment_without_a_group_key_is_ungrouped() {
    let fragment = parse_str(
        indoc! {"
            ---
            demo: patch
            ---

            Fix the parser.
        "},
        "plain.md",
    )
    .expect("parse");
    assert_eq!(fragment.group, None);
}

#[test]
fn an_unknown_group_fails() {
    let text = err_text(parse_str(
        indoc! {r#"
            ---
            demo: patch
            "verctl:group": security
            ---

            Fix the parser.
        "#},
        "bad.md",
    ));
    assert!(
        text.contains(r#"unknown verctl:group "security""#),
        "{text}"
    );
}