acorn-schema 0.3.1

Portable ACORN schema, validation, and codecs
Documentation
#![allow(
    clippy::arithmetic_side_effects,
    clippy::expect_used,
    clippy::indexing_slicing,
    clippy::panic,
    clippy::unwrap_used
)]
use super::*;
use std::collections::BTreeSet;

const ALL_SUPPORTED_PID_FIXTURE: &str = include_str!("../../../../tests/fixtures/gather/all-supported-pids.txt");
const ALL_SUPPORTED_PID_CASES: [(PID, &str, &str); 13] = [
    (PID::ARK, "https://n2t.net/ark:/13030/xf93gt2q", "invalid ARK value"),
    (PID::Arxiv, "https://arxiv.org/abs/2106.09685", "arXiv:2100.00000"),
    (PID::DOI, "https://doi.org/10.1000/182", "invalid DOI value"),
    (PID::Handle, "https://hdl.handle.net/10013/epic.10033", "invalid Handle value"),
    (PID::ISBN, "978-0-306-40627-0", "0-306-40615-3"),
    (PID::ISNI, "https://isni.org/isni/0000000492299539", "0000000492299538"),
    (PID::ORCID, "https://orcid.org/0000-0002-2057-9115", "0000-0002-2057-9114"),
    (PID::Patent, "US20250123456A2", "US ABC123 B2"),
    (PID::RAID, "RAID:10.83962/fb5be317", "11.83962/fb5be317"),
    (PID::ROR, "https://ror.org/01qz5mb56", "01qz5mb55"),
    (PID::RRID, "RRID:SCR_003512", "https://example.org/RRID:BAD"),
    (
        PID::SWHID,
        "swh:1:cnt:4d99d2d18326621ccdd70f5ea66c2e2ac236ad8b",
        "swh:2:cnt:4d99d2d18326621ccdd70f5ea66c2e2ac236ad8b",
    ),
    (PID::URL, "https://example.org/research/artifact", "ftp://example.org/research/artifact"),
];

const MOD_10_OR_11_CHECK_DIGIT_CASES: [(&str, Option<&[char]>); 6] = [
    ("0-306-40615-2", Some(&['2'])),
    ("0-8044-2957-X", Some(&['X'])),
    ("978-0-306-40627-0", Some(&['0'])),
    ("978-3-16-148410-0", Some(&['0'])),
    ("978-3-16-A48410-0", None),
    ("invalid", None),
];
const MOD_11_2_CHECK_DIGIT_CASES: [(&str, Option<&[char]>); 4] = [
    ("000000031415926", Some(&['9'])),
    ("0000000492299539", Some(&['9'])),
    ("0000 0004 9229 9539", Some(&['9'])),
    ("0000-0002-2816-415X", Some(&['X'])),
];
const MOD_97_10_CHECK_DIGIT_CASES: [(&str, Option<&[char]>); 5] = [
    ("0q6v6102", Some(&['0', '2'])),
    ("1qz5mb", Some(&['5', '6'])),
    ("1wass4", Some(&['8', '0'])),
    ("2xn1ny", Some(&['0', '6'])),
    ("******", None),
];
const NOID_CHECK_DIGIT_CASES: [(&str, Option<&[char]>); 2] = [("13030/xf93gt2", Some(&['q'])), ("c8131/g3js3", Some(&['v']))];

#[test]
fn test_all_supported_pid_fixture_covers_valid_and_invalid_instances() {
    assert_eq!(ALL_SUPPORTED_PID_FIXTURE.replace("\r\n", "\n").split("\n\n").count(), 4);
    let fixture_kinds = ALL_SUPPORTED_PID_CASES.iter().map(|(kind, _, _)| kind.clone()).collect::<BTreeSet<_>>();
    let supported_kinds = PID::iter().filter(|kind| kind.is_discoverable()).collect::<BTreeSet<_>>();
    assert_eq!(fixture_kinds, supported_kinds);
    ALL_SUPPORTED_PID_CASES.iter().for_each(|(kind, valid, invalid)| {
        assert_eq!(
            ALL_SUPPORTED_PID_FIXTURE.matches(valid).count(),
            1,
            "fixture should contain one valid {kind}"
        );
        assert_eq!(
            ALL_SUPPORTED_PID_FIXTURE.matches(invalid).count(),
            1,
            "fixture should contain one invalid {kind}"
        );
        assert!(
            Identifier {
                kind: kind.clone(),
                value: (*valid).to_string(),
            }
            .normalized()
            .is_some_and(|identifier| identifier.kind == *kind),
            "expected valid {kind}: {valid}"
        );
        assert!(
            Identifier {
                kind: kind.clone(),
                value: (*invalid).to_string(),
            }
            .normalized()
            .is_none(),
            "expected invalid {kind}: {invalid}"
        );
    });
    let discovered_kinds = Identifier::find_all(ALL_SUPPORTED_PID_FIXTURE)
        .into_iter()
        .map(|identifier| identifier.kind)
        .collect::<BTreeSet<_>>();
    assert_eq!(discovered_kinds, supported_kinds);
}

#[test]
fn test_betanumeric() {
    assert!('9'.is_betanumeric());
    assert!(!'a'.is_betanumeric());
    assert!(!'/'.is_betanumeric());
    assert_eq!('0'.to_betanumeric_ordinal(), Some(0));
    assert_eq!('z'.to_betanumeric_ordinal(), Some(28));
}

#[test]
fn test_identifier_normalize_uses_declared_kind() {
    let doi = Identifier {
        kind: PID::DOI,
        value: "https://doi.org/10.1234/ABC".to_string(),
    };
    let kind: &str = (&doi).into();
    assert_eq!(kind, "doi");
    assert_eq!(doi.normalized().unwrap().value, "10.1234/ABC");
    let mismatched = Identifier {
        kind: PID::ISBN,
        value: "https://doi.org/10.1234/ABC".to_string(),
    };
    assert!(mismatched.normalized().is_none());
}

#[test]
fn test_identifier_unknown_name_identifier_is_ambiguous() {
    assert!(Identifier::new("0000-0002-2057-9115").normalized().is_none());
    assert!(Identifier::new("0000000492299539").normalized().is_none());
    assert_eq!(Identifier::new("ORCID: 0000-0002-2057-9115").normalized().unwrap().kind, PID::ORCID);
    assert_eq!(Identifier::new("ISNI: 0000 0004 9229 9539").normalized().unwrap().kind, PID::ISNI);
}

#[test]
fn test_idutils_url_cases() {
    [
        (
            "http://nbn-resolving.org/urn:nbn:de:bvb:19-146642",
            "http://nbn-resolving.org/urn:nbn:de:bvb:19-146642",
        ),
        ("http://pubmed.ncbi.nlm.nih.gov/12082125/", "http://pubmed.ncbi.nlm.nih.gov/12082125"),
        ("https://example.org/end.),;:!?", "https://example.org/end"),
        ("https://viaf.org/viaf/75121530", "https://viaf.org/viaf/75121530"),
    ]
    .into_iter()
    .for_each(|(value, expected)| {
        let normalized = Identifier::new(value).normalized().expect("expected valid URL");
        assert_eq!(normalized.kind, PID::URL, "{value}");
        assert_eq!(normalized.value, expected, "{value}");
    });
}

#[test]
fn test_mod_10_or_11_check_digit() {
    MOD_10_OR_11_CHECK_DIGIT_CASES.into_iter().for_each(|(value, expected)| {
        assert_eq!(mod_10_or_11_check_digit(value).as_deref(), expected, "unexpected check digit for {value}");
    });
}

#[test]
fn test_mod_11_2_check_digit() {
    MOD_11_2_CHECK_DIGIT_CASES.into_iter().for_each(|(value, expected)| {
        assert_eq!(mod_11_2_check_digit(value).as_deref(), expected, "unexpected check digit for {value}");
    });
}

#[test]
fn test_mod_97_10_check_digit() {
    MOD_97_10_CHECK_DIGIT_CASES.into_iter().for_each(|(value, expected)| {
        assert_eq!(mod_97_10_check_digit(value).as_deref(), expected, "unexpected check digit for {value}");
    });
}

#[test]
fn test_noid_check_digit() {
    NOID_CHECK_DIGIT_CASES.into_iter().for_each(|(value, expected)| {
        assert_eq!(noid_check_digit(value).as_deref(), expected, "unexpected check digit for {value}");
    });
}

#[test]
fn test_normalize_discovery_identifiers() {
    assert_eq!(Identifier::new("https://doi.org/10.1234/ABC").normalized().unwrap().value, "10.1234/ABC");
    assert_eq!(Identifier::new("ISBN 978-0-306-40627-0").normalized().unwrap().kind, PID::ISBN);
    assert_eq!(Identifier::new("ark:/12345/abc").normalized().unwrap().kind, PID::ARK);
    assert_eq!(Identifier::new("RAID:10.12345/xyz").normalized().unwrap().kind, PID::RAID);
    assert_eq!(Identifier::new("RRID:SCR_003512").normalized().unwrap().kind, PID::RRID);
    assert_eq!(Identifier::new("hdl:10013/epic.10033").normalized().unwrap().kind, PID::Handle);
    assert_eq!(Identifier::new("10013/epic.10033").normalized().unwrap().kind, PID::Handle);
    assert_eq!(Identifier::new("US-123456-A1").normalized().unwrap().kind, PID::Patent);
    assert_eq!(
        Identifier::new("swh:1:cnt:ce013625030ba8dba906f756967f9e9ca394464a")
            .normalized()
            .unwrap()
            .kind,
        PID::SWHID
    );
}

#[test]
fn test_normalize_discovery_url_preserves_case_sensitive_components() {
    let normalized = Identifier::new("https://Example.org/Data/Artifact?Key=Value").normalized().unwrap();
    assert_eq!(normalized.kind, PID::URL);
    assert_eq!(normalized.value, "https://Example.org/Data/Artifact?Key=Value");
}

#[test]
fn test_normalize_pid_values_filters_invalid_values_and_duplicates() {
    assert_eq!(
        normalize::<ISNI>(vec![
            "https://isni.org/isni/0000000492299539".to_string(),
            "0000000492299538".to_string(),
            "0000 0004 9229 9539".to_string(),
        ]),
        ["0000000492299539"]
    );
    assert_eq!(
        normalize::<ORCID>(vec![
            "https://orcid.org/0000-0002-2057-9115".to_string(),
            "0000-0002-2057-9114".to_string(),
        ]),
        ["0000-0002-2057-9115"]
    );
}

#[test]
fn test_pid_display_and_serialization_use_lowercase_wire_names() {
    assert_eq!(PID::Arxiv.to_string(), "arxiv");
    assert_eq!(PID::RRID.to_string(), "rrid");
    assert_eq!(serde_json::to_string(&PID::DOI).unwrap(), r#""doi""#);
    assert_eq!(serde_json::from_str::<PID>(r#""doi""#).unwrap(), PID::DOI);
    assert_eq!(serde_json::from_str::<PID>(r#""DOI""#).unwrap(), PID::DOI);
}

#[test]
fn test_pid_from_str() {
    assert_eq!(PID::from("doi"), PID::DOI);
    assert_eq!(PID::from("ORCID"), PID::ORCID);
    assert_eq!(PID::from(" pidinst "), PID::PIDINST);
    assert_eq!(PID::from("SWHID"), PID::SWHID);
    assert_eq!(PID::from("RRID"), PID::RRID);
    assert_eq!(PID::from("unsupported"), PID::Unknown);
}

#[test]
fn test_pid_infer() {
    assert_eq!(PID::infer("doi"), PID::DOI);
    assert_eq!(PID::infer(String::from("https://orcid.org/")), PID::ORCID);
    assert_eq!(PID::infer("https://isni.org/isni/"), PID::ISNI);
    assert_eq!(PID::infer("unsupported"), PID::Unknown);
}

#[test]
fn test_rrid_is_discoverable_but_not_project_identifying() {
    assert!(PID::RRID.is_discoverable());
    assert!(!PID::RRID.is_project_identifier());
    let identifiers = Identifier::find_all("RRID:SCR_003512 https://n2t.net/RRID:SCR_003512");
    assert_eq!(identifiers, [Identifier::from(RRID::from_string("RRID:SCR_003512"))]);
}

#[test]
fn test_swhid_identity_ignores_qualifiers() {
    let core = Identifier::new("swh:1:cnt:ce013625030ba8dba906f756967f9e9ca394464a")
        .normalized()
        .unwrap();
    let qualified = Identifier::new("swh:1:cnt:ce013625030ba8dba906f756967f9e9ca394464a;origin=https://example.org/source;lines=1-2")
        .normalized()
        .unwrap();
    assert_eq!(core.identity_key(), qualified.identity_key());
}