gedcomkit 0.1.7

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
//! Property-style tests: invariants asserted over thousands of generated
//! cases rather than a handful of examples.
//!
//! The generator is the same deterministic xorshift the synthetic fixture
//! uses — no dependency, and a failure reproduces by seed. These are the
//! properties a fuzzer would hunt, run in-tree on every `cargo test`; the
//! `fuzz/` targets chase the same invariants with coverage guidance.

#![allow(
    clippy::cast_possible_truncation,
    reason = "generator indices are tiny by construction"
)]

use gedcomkit::dates::GedcomDate;
use gedcomkit::{Document, Node, decode_gedcom};

/// The line pool for the parse-and-write fixed-point property.
const LINES: &[&str] = &[
    "0 HEAD",
    "1 GEDC",
    "2 VERS 7.0",
    "0 @I1@ INDI",
    "0  _PUBLISH",
    "1 NAME Ada /Example/",
    "1 NOTE  leading space kept",
    "2 CONT",
    "2 CONC  and more",
    "1 _EXT @VOID@",
    "0 TRLR",
];

/// The token pool for the date-parser property.
const DATE_PIECES: &[&str] = &[
    "ABT",
    "BET",
    "AND",
    "FROM",
    "TO",
    "1882",
    "99999",
    "JAN",
    "13",
    "0",
    "/",
    ".",
    "-",
    "(",
    ")",
    "@#DJULIAN@",
    "BCE",
    "février",
    "<",
    ">",
    "th",
    "age 26",
    "\u{a0}",
    "",
];

/// `xorshift64*`: short, fast, deterministic.
struct Rng(u64);

impl Rng {
    const fn new(seed: u64) -> Self {
        Self(seed | 1)
    }

    const fn next(&mut self) -> u64 {
        let mut value = self.0;
        value ^= value >> 12;
        value ^= value << 25;
        value ^= value >> 27;
        self.0 = value;
        value.wrapping_mul(0x2545_F491_4F6C_DD1D)
    }

    const fn below(&mut self, ceiling: u64) -> u64 {
        if ceiling == 0 {
            0
        } else {
            self.next() % ceiling
        }
    }
}

/// A tag the parser must accept back: letters, digits, underscore.
fn arbitrary_tag(rng: &mut Rng) -> String {
    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
    let length = 1 + rng.below(6) as usize;
    (0..length)
        .map(|_| char::from(ALPHABET[rng.below(ALPHABET.len() as u64) as usize]))
        .collect()
}

/// A payload drawn from the shapes real files hold: plain words, leading and
/// doubled spaces, non-ASCII, an at-sign, newlines (which the builder must
/// turn into continuations).
fn arbitrary_payload(rng: &mut Rng) -> String {
    const PIECES: &[&str] = &[
        "alpha",
        "Testwright",
        "1882",
        "  doubled",
        "naïve",
        "@VOID@",
        "line\nbreak",
        "…",
        "/slash/",
        "trailing ",
        "a\nb\nc",
        "5 JAN 1882",
    ];
    let count = 1 + rng.below(3);
    let mut payload = String::new();
    for index in 0..count {
        if index > 0 {
            payload.push(' ');
        }
        payload.push_str(PIECES[rng.below(PIECES.len() as u64) as usize]);
    }
    payload
}

fn arbitrary_document(rng: &mut Rng) -> Document {
    let mut document = Document::new_v7();
    let records = 1 + rng.below(6) as usize;
    for index in 0..records {
        let mut record = Node::record(format!("@X{index}@"), arbitrary_tag(rng));
        let children = rng.below(4);
        for _ in 0..children {
            let mut child = Node::with_value(arbitrary_tag(rng), arbitrary_payload(rng));
            if rng.below(2) == 0 {
                child.push(Node::with_value(arbitrary_tag(rng), arbitrary_payload(rng)));
            }
            record.push(child);
        }
        let position = document.records.len() - 1;
        document.records.insert(position, record);
    }
    document
}

#[test]
fn any_document_the_builders_produce_reparses_to_the_same_text() {
    let mut rng = Rng::new(0x00D0_C5EE_D000_0001);
    for case in 0..2_000 {
        let document = arbitrary_document(&mut rng);
        let text = document.to_text();
        let reread = Document::parse(&text).unwrap_or_else(|error| {
            panic!("case {case}: built text did not parse: {error}\n{text}")
        });
        assert_eq!(reread.to_text(), text, "case {case} was not idempotent");
    }
}

#[test]
fn parsing_and_writing_arbitrary_parseable_text_is_idempotent() {
    // Whatever odd-but-accepted lines come out of the generator — extra
    // delimiter spaces included — one parse→write must be a fixed point.
    let mut rng = Rng::new(0x00D0_C5EE_D000_0002);
    for case in 0..2_000 {
        let count = 1 + rng.below(12);
        let mut text = String::from("0 HEAD\n");
        for _ in 0..count {
            text.push_str(LINES[rng.below(LINES.len() as u64) as usize]);
            text.push('\n');
        }
        let Ok(document) = Document::parse(&text) else {
            continue; // a level-skipping shuffle is rightly refused
        };
        let written = document.to_text();
        let again = Document::parse(&written)
            .unwrap_or_else(|error| panic!("case {case}: output did not re-parse: {error}"));
        assert_eq!(again.to_text(), written, "case {case} was not idempotent");
    }
}

#[test]
fn set_logical_value_inverts_logical_value_for_arbitrary_text() {
    let mut rng = Rng::new(0x00D0_C5EE_D000_0003);
    for case in 0..2_000 {
        let payload = arbitrary_payload(&mut rng);
        let mut node = Node::with_value("NOTE", "old");
        node.push(Node::with_value("SOUR", "@S1@"));
        node.set_logical_value(&payload);
        assert_eq!(node.logical_value(), payload, "case {case}");
        assert_eq!(
            node.all("SOUR").count(),
            1,
            "case {case} lost an unrelated substructure"
        );
    }
}

#[test]
fn the_date_parser_never_panics_and_never_loses_the_original() {
    let mut rng = Rng::new(0x00D0_C5EE_D000_0004);
    for _ in 0..5_000 {
        let count = rng.below(6);
        let mut payload = String::new();
        for index in 0..count {
            if index > 0 {
                payload.push(' ');
            }
            payload.push_str(DATE_PIECES[rng.below(DATE_PIECES.len() as u64) as usize]);
        }
        let date = GedcomDate::parse(&payload);
        assert_eq!(date.original, payload, "the original is never rewritten");
    }
}

#[test]
fn the_decoder_never_panics_on_arbitrary_bytes() {
    let mut rng = Rng::new(0x00D0_C5EE_D000_0005);
    for _ in 0..2_000 {
        let length = rng.below(512) as usize;
        let bytes: Vec<u8> = (0..length).map(|_| (rng.next() & 0xFF) as u8).collect();
        // Decoding may report damage; it must never panic, and what it
        // returns must be parseable or refused, never a crash either way.
        if let Ok((text, _)) = decode_gedcom(&bytes) {
            let _ = Document::parse(&text);
        }
    }
}