gedcomkit 0.1.4

A byte-preserving GEDCOM document model: decoding, parsing, readings, version conversion, plausibility checks, and the GEDZIP container, for GEDCOM 5.5 through 7.x.
Documentation
//! The crate's hand-written vocabulary, checked against the specification's
//! own machine-readable tables.
//!
//! `fixtures/vendored/gedcom-spec/` holds the auto-generated
//! `extracted-files` of `FamilySearch` GEDCOM at a pinned tag (Apache-2.0; see
//! its README). These tests read those tables rather than restating them, so
//! taking a newer spec release — re-downloading the files — turns any change
//! the crate has not absorbed into a failing test instead of silent drift.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

/// The spec tag the vendored artifacts were extracted from. Bump alongside a
/// re-download, so a mismatch between the README and the tests cannot hide.
const SPEC_TAG: &str = "v7.0.18";

fn spec_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/vendored/gedcom-spec")
}

fn spec_file(name: &str) -> String {
    let path = spec_root().join(name);
    std::fs::read_to_string(&path)
        .unwrap_or_else(|error| panic!("{} ({SPEC_TAG}): {error}", path.display()))
}

/// The tag named by a gedcom.io term URI: the last URI segment, with any
/// context prefix removed — `record-SOUR` names `SOUR`, `FAMC-ADOP` names
/// `ADOP`, `enum-CENS` names `CENS`. Tags may hold underscores but never
/// hyphens, so the split is unambiguous.
fn tag_of(uri: &str) -> &str {
    let term = uri.rsplit('/').next().unwrap_or(uri);
    term.rsplit('-').next().unwrap_or(term)
}

/// Every structure tag the specification defines, from the tag column of
/// `substructures.tsv` plus the record types named by `record-*` terms.
fn specified_tags() -> BTreeSet<String> {
    let mut tags = BTreeSet::new();
    for line in spec_file("substructures.tsv").lines() {
        let mut columns = line.split('\t');
        let superstructure = columns.next().unwrap_or_default();
        if let Some(tag) = columns.next() {
            tags.insert(tag.to_owned());
        }
        let term = superstructure.rsplit('/').next().unwrap_or_default();
        if let Some(record) = term.strip_prefix("record-") {
            tags.insert(record.to_owned());
        }
    }
    tags
}

#[test]
fn every_structure_tag_the_specification_defines_has_a_label() {
    // A tag the interface would render as its raw spelling is not an error —
    // the crate shows what it does not understand — but a *standard* tag
    // without a label means the vocabulary is behind the specification.
    let missing: Vec<String> = specified_tags()
        .into_iter()
        .filter(|tag| gedcomkit::tags::label(tag).is_none())
        .collect();

    assert!(
        missing.is_empty(),
        "standard {SPEC_TAG} tags without a label: {missing:?}"
    );
}

#[test]
fn the_specifications_event_list_matches_the_crates() {
    // `enumset-EVEN` is the specification's own list of event structures.
    let mut specified = BTreeSet::new();
    for line in spec_file("enumerationsets.tsv").lines() {
        let mut columns = line.split('\t');
        let set = columns.next().unwrap_or_default();
        let member = columns.next().unwrap_or_default();
        if set.ends_with("/enumset-EVEN") {
            specified.insert(tag_of(member).to_owned());
        }
    }
    assert!(
        specified.len() >= 30,
        "the event set read wrong: {specified:?}"
    );

    for tag in &specified {
        assert!(
            gedcomkit::tags::is_event(tag),
            "{tag} is an event in {SPEC_TAG} and not here"
        );
    }
    // And nothing the crate calls an event is absent from the specification's
    // list — `EVEN` itself is the generic structure the set is named after
    // rather than a member of it.
    for tag in [
        "BIRT", "CHR", "DEAT", "BURI", "CREM", "ADOP", "BAPM", "BARM", "BASM", "BLES", "CHRA",
        "CONF", "FCOM", "ORDN", "NATU", "EMIG", "IMMI", "CENS", "PROB", "WILL", "GRAD", "RETI",
        "MARR", "DIV", "DIVF", "ANUL", "ENGA", "MARB", "MARC", "MARL", "MARS",
    ] {
        assert!(
            specified.contains(tag),
            "the crate calls {tag} an event and {SPEC_TAG} does not"
        );
    }
}

#[test]
fn the_specifications_attribute_list_matches_the_crates() {
    // `enumset-EVENATTR` is events plus attributes; what it adds over
    // `enumset-EVEN` is the attribute list (plus the two generic structures).
    let mut events = BTreeSet::new();
    let mut all = BTreeSet::new();
    for line in spec_file("enumerationsets.tsv").lines() {
        let mut columns = line.split('\t');
        let set = columns.next().unwrap_or_default();
        let member = tag_of(columns.next().unwrap_or_default()).to_owned();
        if set.ends_with("/enumset-EVEN") {
            events.insert(member);
        } else if set.ends_with("/enumset-EVENATTR") {
            all.insert(member);
        }
    }

    let attributes: Vec<&String> = all.difference(&events).collect();
    assert!(!attributes.is_empty(), "the attribute set read wrong");
    for tag in attributes {
        assert!(
            gedcomkit::tags::is_event(tag) || gedcomkit::tags::is_attribute(tag),
            "{tag} is an event or attribute in {SPEC_TAG} and neither here"
        );
    }
}

#[test]
fn every_enumerated_value_the_specification_defines_reads_back_as_itself_or_words() {
    // The crate never validates enumerated payloads — it labels the ones it
    // knows and returns the rest unchanged. This asserts the second half:
    // no specified value is swallowed or rewritten into something else.
    for line in spec_file("enumerationsets.tsv").lines() {
        let mut columns = line.split('\t');
        let set = columns.next().unwrap_or_default();
        let value = tag_of(columns.next().unwrap_or_default());
        let read = match set.rsplit('/').next().unwrap_or_default() {
            "enumset-PEDI" => gedcomkit::tags::pedigree_label(value),
            "enumset-QUAY" => gedcomkit::tags::certainty_label(value),
            _ => continue,
        };
        assert!(
            !read.is_empty(),
            "{value} from {set} came back empty rather than labelled or kept"
        );
    }
}

#[test]
fn the_datatype_grammar_the_crate_hand_wrote_is_still_the_specifications() {
    // The date and age grammars in `dates.rs` were transcribed by hand. The
    // ABNF is the authority; if a spec patch changes these productions (as
    // 7.0.17 added Latitude/Longitude/URI), this fingerprint fails and says
    // where to look.
    let grammar = spec_file("grammar.abnf");
    for production in [
        "date        = [calendar D] [[day D] month D] year [D epoch]",
        "dateRange   = %s\"BET\" D date D %s\"AND\" D date",
        "epoch   = %s\"BCE\" / extTag",
        "month   = stdTag / extTag",
        "Time     =  hour \":\" minute [\":\" second [\".\" fraction]] [%s\"Z\"]",
        "Age         = [[ageBound D] ageDuration]",
        "Latitude = (\"N\" / \"S\") upto90 [ \".\" 1*digit]",
        "Longitude = (\"N\" / \"S\") upto180 [ \".\" 1*digit]",
    ] {
        assert!(
            grammar.contains(production),
            "the {SPEC_TAG} ABNF no longer contains {production:?} — \
             re-read the datatype against dates.rs and validate.rs"
        );
    }
}