gedcomkit 0.1.9

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
//! Conversion between GEDCOM versions, gated on the specification's own files.
//!
//! Plan section 8.2 names the fixtures: "the official maximal GEDCOM 7 sample
//! and the 5.5.1 torture files are the fixtures that gate both directions."
//! They are fetched by `scripts/fetch-fixtures.ps1` into gitignored
//! `fixtures/external/`, so this skips when they are not there.
//!
//! Skipping is deliberate and is not a pass. The release/test matrix is
//! maintained with the root release checklist, and a run that skipped must be
//! recorded as skipped.

use gedcomkit::convert::{ConversionKind, to_version_5_5_1, to_version_7};
use gedcomkit::{Document, decode_gedcom};
use std::path::{Path, PathBuf};

fn fixture(relative: &str) -> Option<PathBuf> {
    let path = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("fixtures/external")
        .join(relative);
    path.is_file().then_some(path)
}

fn read(path: &Path) -> Document {
    let bytes = std::fs::read(path).expect("read the fixture");
    let (text, _) = decode_gedcom(&bytes).expect("decode the fixture");
    Document::parse(&text).expect("parse the fixture")
}

/// The official maximal sample is the hardest thing version 7 can say, so it
/// is the right file to ask what 5.5.1 cannot hold.
#[test]
fn the_maximal_version_seven_sample_converts_down_and_back() {
    let Some(path) = fixture("gedcom-io/gedcom70/maximal70.ged") else {
        eprintln!("skipped: run scripts/fetch-fixtures.ps1 for the gedcom.io samples");
        return;
    };
    let original = read(&path);
    let records = original.records.len();
    assert!(records > 10, "the maximal sample should be large");

    let down = to_version_5_5_1(&original);
    assert_eq!(down.to, "5.5.1");
    assert_eq!(
        down.document.records.len(),
        records,
        "converting a version cannot lose a record"
    );
    let text = down.document.to_text();
    assert!(text.contains("2 VERS 5.5.1"), "the version is declared");
    assert!(
        text.contains("1 CHAR UTF-8"),
        "5.5.1 requires a character set"
    );
    assert!(!text.contains("\n1 SCHMA"), "the declarations went");

    // What 5.5.1 cannot hold is named rather than dropped, and it is still in
    // the document afterwards.
    let kept: Vec<&str> = down
        .notes
        .iter()
        .filter(|note| note.kind == ConversionKind::Kept)
        .map(|note| note.tag.as_str())
        .collect();
    assert!(
        !kept.is_empty(),
        "the maximal sample uses constructs 5.5.1 has no place for"
    );
    for tag in &kept {
        assert!(
            text.contains(&format!(" {tag}")),
            "{tag} was reported as kept and is not in the document"
        );
    }

    // And back up again.
    let up = to_version_7(&down.document);
    assert_eq!(up.to, "7.0");
    assert_eq!(up.document.records.len(), records);
    let text = up.document.to_text();
    assert!(text.contains("2 VERS 7.0"));
    assert!(
        !text.contains("\n1 CHAR "),
        "version 7 has no character set"
    );
    assert!(
        Document::parse(&text).is_ok(),
        "what a conversion writes has to read back"
    );
}

/// The torture files are 5.5.1 at its most awkward, which is the right input
/// for the direction a user actually takes: an old file into a current one.
#[test]
fn the_five_five_one_torture_files_convert_up() {
    let mut ran = 0;
    for name in [
        "torture/TGC551.ged",
        "torture/TGC55C.ged",
        "torture/TGC551LF.ged",
    ] {
        let Some(path) = fixture(name) else {
            continue;
        };
        ran += 1;
        let original = read(&path);
        let outcome = to_version_7(&original);

        assert_eq!(
            outcome.document.records.len(),
            original.records.len(),
            "{name} lost a record"
        );
        let text = outcome.document.to_text();
        assert!(text.contains("2 VERS 7.0"), "{name}");
        assert!(!text.contains("\n1 CHAR "), "{name} kept its character set");
        Document::parse(&text).unwrap_or_else(|error| {
            panic!("{name} did not read back after conversion: {error}");
        });

        // Every note record that moved took its pointers with it: a version 7
        // file with `SNOTE` records and `NOTE` pointers at them is broken in a
        // way no reader will report.
        let shared: Vec<String> = outcome
            .document
            .records_of("SNOTE")
            .filter_map(|record| record.xref().map(str::to_owned))
            .collect();
        for record in &outcome.document.records {
            for node in record.walk() {
                if node.tag() == "NOTE"
                    && let Some(pointer) = node.pointer()
                {
                    assert!(
                        !shared.iter().any(|xref| xref.eq_ignore_ascii_case(pointer)),
                        "{name} left a NOTE pointing at a shared note record"
                    );
                }
            }
        }
        assert!(
            outcome.document.unresolved_pointers().len() <= original.unresolved_pointers().len(),
            "{name} gained an unresolved pointer"
        );
    }

    if ran == 0 {
        eprintln!("skipped: run scripts/fetch-fixtures.ps1 for the 5.5.1 torture files");
    }
}