gedcomkit 0.1.11

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
//! GEDCOM 7's extension declarations: `HEAD`.`SCHMA`.`TAG`.
//!
//! Version 7 asks every file to say what its extension tags mean, as
//! `2 TAG _EXAMPLE https://where.example/it/is/documented` lines under a
//! `SCHMA` structure in the header. This module reads and writes those
//! declarations; deciding *which* tags to declare, and against which URIs, is
//! the caller's business, because the tags an application owns are the one
//! thing a general library cannot know.

use crate::Node;

/// Whether the header already declares this tag.
///
/// Any URI counts. A file written by an earlier version of an application may
/// declare the same tag against a different URI, and re-declaring it would
/// leave two entries for one tag — which is worse than an out-of-date URI.
#[must_use]
pub fn is_declared(header: &Node, tag: &str) -> bool {
    header.all("SCHMA").any(|schma| {
        schma
            .all("TAG")
            .any(|declaration| declaration.logical_value().split_whitespace().next() == Some(tag))
    })
}

/// Writes the declaration for a tag into a header, if it is not already there.
///
/// Returns whether the header changed, so a caller can leave an untouched
/// header untouched — the byte-for-byte guarantee is about records nothing
/// altered, and a header that gains a declaration has been altered.
pub fn declare(header: &mut Node, tag: &str, uri: &str) -> bool {
    if is_declared(header, tag) {
        return false;
    }
    let declaration = Node::with_value("TAG", format!("{tag} {uri}"));
    if let Some(schma) = header.first_mut("SCHMA") {
        schma.push(declaration);
    } else {
        header.push(Node::new("SCHMA").child(declaration));
    }
    true
}

/// Every declared tag and its URI, in header order.
///
/// Read from every `SCHMA` the header carries. A declaration with no URI is
/// returned with an empty one, because the tag half is still a statement
/// worth reading.
#[must_use]
pub fn declarations(header: &Node) -> Vec<(String, String)> {
    let mut found = Vec::new();
    for schma in header.all("SCHMA") {
        for declaration in schma.all("TAG") {
            let value = declaration.logical_value();
            let mut parts = value.split_whitespace();
            if let Some(tag) = parts.next() {
                found.push((tag.to_owned(), parts.collect::<Vec<_>>().join(" ")));
            }
        }
    }
    found
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Document;

    fn header() -> Node {
        Node::new("HEAD").child(Node::new("GEDC").child(Node::with_value("VERS", "7.0")))
    }

    #[test]
    fn a_declaration_is_written_once_and_only_once() {
        let mut head = header();
        assert!(declare(&mut head, "_EX", "https://where.example/EX"));
        assert!(
            !declare(&mut head, "_EX", "https://where.example/EX"),
            "a second call must not write a second declaration"
        );
        assert!(declare(&mut head, "_OTHER", "https://where.example/OTHER"));

        let text = Document {
            records: vec![head],
        }
        .to_text();
        assert_eq!(text.matches("2 TAG _EX ").count(), 1, "{text}");
        assert_eq!(text.matches("1 SCHMA").count(), 1, "{text}");
        assert!(text.contains("2 TAG _OTHER https://"), "{text}");
    }

    #[test]
    fn a_declaration_already_in_the_file_is_left_exactly_as_it_is() {
        let document = Document::parse(
            "0 HEAD\n\
             1 SCHMA\n\
             2 TAG _EX https://somebody.example/their/own/uri\n\
             0 TRLR\n",
        )
        .expect("parse");
        let mut head = document.header().expect("header").clone();

        assert!(!declare(&mut head, "_EX", "https://where.example/EX"));
        let text = Document {
            records: vec![head],
        }
        .to_text();
        assert!(text.contains("somebody.example"), "{text}");
        assert_eq!(text.matches("TAG _EX").count(), 1, "{text}");
    }

    #[test]
    fn declarations_are_read_back_with_their_uris() {
        let mut head = header();
        assert!(declare(&mut head, "_EX", "https://where.example/EX"));
        assert_eq!(
            declarations(&head),
            [("_EX".to_owned(), "https://where.example/EX".to_owned())]
        );
        assert!(!is_declared(&head, "_OTHER"));
        assert!(is_declared(&head, "_EX"));
    }
}