recipemd 0.2.0

Parser for the RecipeMD format
Documentation
use std::{path::PathBuf, sync::LazyLock};

use miette::IntoDiagnostic;
use recipemd::Recipe;

static TESTCASE_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
    let testcase_dir = PathBuf::from("./recipemd/testcases/cases");
    assert!(
        testcase_dir.exists(),
        "repo must be cloned with --recurse-submodules"
    );
    testcase_dir
});

mod valid {
    use super::*;

    use pretty_assertions::assert_eq;

    macro_rules! testcase {
        ( $name:ident $( , ignore $( = $reason:literal )? )? ) => {
            #[test]
            $(
                #[ignore $( = $reason )? ]
            )?
            fn $name() -> miette::Result<()> {
                valid(stringify!($name))
            }
        };
    }

    fn valid(name: &str) -> miette::Result<()> {
        let md_path = TESTCASE_DIR.join(format!("{name}.md"));
        let json_path = TESTCASE_DIR.join(format!("{name}.json"));

        let md = std::fs::read_to_string(&md_path).into_diagnostic()?;
        let json = std::fs::read_to_string(&json_path).into_diagnostic()?;

        let ours = Recipe::parse(&md)?;
        let reference: Recipe = serde_json::from_str(&json).into_diagnostic()?;

        assert_eq!(ours, reference, "recipes don't match (ours vs. reference)");

        Ok(())
    }

    testcase!(commonmark_fenced_code_blocks);
    testcase!(commonmark_reference_images);
    testcase!(commonmark_reference_links);
    testcase!(ingredients_groups);
    testcase!(
        ingredients_links,
        ignore = "unspecified: differences in parsing whitespace"
    );
    testcase!(
        ingredients_multiline,
        ignore = "unspecified: differences in parsing whitespace"
    );
    testcase!(ingredients_numbered);
    testcase!(ingredients_sublist);
    testcase!(ingredients);
    testcase!(instructions);
    testcase!(recipe);
    testcase!(tags_no_partial);
    testcase!(tags_splitting);
    testcase!(tags_yields);
    testcase!(tags);
    testcase!(title_setext);
    testcase!(title);
    testcase!(yields_tags);
    testcase!(yields);
}

mod invalid {
    use miette::Context;
    use recipemd::ErrorKind;

    use super::*;

    macro_rules! testcase {
        ( $name:ident, $error_kind:expr $( , ignore $( = $reason:literal )? )? ) => {
            #[test]
            $(
                #[ignore $( = $reason )? ]
            )?
            fn $name() -> miette::Result<()> {
                invalid(stringify!($name), $error_kind)
            }
        };
    }

    fn invalid(name: &str, error_kind: ErrorKind) -> miette::Result<()> {
        let md = std::fs::read_to_string(TESTCASE_DIR.join(format!("{name}.invalid.md")))
            .into_diagnostic()?;

        match Recipe::parse(&md) {
            Ok(recipe) => Err(miette::miette!(
                "recipe is invalid but no error was generated\n{recipe:#?}"
            )),
            Err(e) => {
                if e.kind == error_kind {
                    Ok(())
                } else {
                    Err(e).wrap_err(format!(
                        "Expected error \"{error_kind}\" but got different error",
                    ))
                }
            }
        }
    }

    testcase!(empty, ErrorKind::ExpectedTitle);
    testcase!(ingredients_empty, ErrorKind::EmptyIngredient);
    testcase!(ingredients_no_divider, ErrorKind::ExpectedHorizontalLine);
    testcase!(ingredients_no_name, ErrorKind::EmptyIngredient);
    testcase!(instructions_no_divider, ErrorKind::ExpectedHorizontalLine);
    testcase!(tags_multiple, ErrorKind::MultipleTagsSections);
    testcase!(title_second_level_heading, ErrorKind::ExpectedTitle);
    testcase!(yields_multiple, ErrorKind::MultipleYieldsSections);
}