gedcomkit 0.1.3

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
//! Reading a `NAME` structure.
//!
//! GEDCOM writes a personal name as one line with the surname between slashes
//! — `Mira Elowen /North Quill/` — and optionally repeats the pieces as
//! substructures. The two can disagree, and when they do the substructure is
//! the more specific statement and wins for that piece only.
//!
//! As everywhere else in this crate, the payload is never rewritten. What is
//! produced here is a reading used for display, sorting, and search.

use crate::Node;

/// A personal name, read from a `NAME` structure.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct PersonalName {
    /// The payload exactly as the file wrote it, slashes included.
    pub original: String,
    /// Everything before the surname.
    pub given: Option<String>,
    /// The text between the slashes, or the `SURN` substructure.
    pub surname: Option<String>,
    /// A particle that sorts with the given name but belongs to the surname:
    /// `van`, `de`, `von`.
    pub surname_prefix: Option<String>,
    /// A title before the name: `Dr`, `Rev`.
    pub prefix: Option<String>,
    /// What follows the surname: `Jr`, `III`.
    pub suffix: Option<String>,
    /// A nickname, which GEDCOM keeps out of the given name.
    pub nickname: Option<String>,
    /// `NAME.TYPE`: `birth`, `married`, `also known as`, and others.
    pub kind: Option<String>,
}

impl PersonalName {
    /// Reads a `NAME` structure, substructures included.
    ///
    /// ```
    /// use gedcomkit::names::PersonalName;
    ///
    /// let name = PersonalName::from_payload("Mira Elowen /North Quill/");
    /// assert_eq!(name.display(), "Mira Elowen North Quill");
    /// assert_eq!(name.sort_form(), "North Quill, Mira Elowen");
    /// ```
    #[must_use]
    pub fn from_node(node: &Node) -> Self {
        let original = node.logical_value();
        let mut name = Self::from_payload(&original);
        // A substructure is a more specific statement than the payload, so it
        // replaces the piece it names — and only that piece.
        for (tag, field) in [
            ("GIVN", &mut name.given),
            ("SURN", &mut name.surname),
            ("SPFX", &mut name.surname_prefix),
            ("NPFX", &mut name.prefix),
            ("NSFX", &mut name.suffix),
            ("NICK", &mut name.nickname),
            ("TYPE", &mut name.kind),
        ] {
            if let Some(value) = node.value_of(tag).filter(|value| !value.trim().is_empty()) {
                *field = Some(value.trim().to_owned());
            }
        }
        name
    }

    /// Reads the payload alone, for a caller that has only the line.
    #[must_use]
    pub fn from_payload(payload: &str) -> Self {
        let original = payload.to_owned();
        let mut name = Self {
            original,
            ..Self::default()
        };

        let opening = payload.find('/');
        let closing =
            opening.and_then(|start| payload[start + 1..].find('/').map(|end| start + 1 + end));

        match (opening, closing) {
            (Some(start), Some(end)) => {
                name.given = trimmed(&payload[..start]);
                name.surname = trimmed(&payload[start + 1..end]);
                name.suffix = trimmed(&payload[end + 1..]);
            }
            // An unclosed slash is malformed but common enough to read: take
            // everything after it as the surname rather than losing the name.
            (Some(start), None) => {
                name.given = trimmed(&payload[..start]);
                name.surname = trimmed(&payload[start + 1..]);
            }
            // No slashes at all. Producers that write this mean the whole
            // string as a given name, and guessing which word is the surname
            // would be inventing a fact.
            _ => name.given = trimmed(payload),
        }
        name
    }

    /// The name as it should be shown: the payload without its slashes, or the
    /// pieces joined when the payload had none.
    #[must_use]
    pub fn display(&self) -> String {
        let mut parts = Vec::new();
        if let Some(prefix) = &self.prefix {
            parts.push(prefix.as_str());
        }
        if let Some(given) = &self.given {
            parts.push(given.as_str());
        }
        if let Some(prefix) = &self.surname_prefix {
            parts.push(prefix.as_str());
        }
        if let Some(surname) = &self.surname {
            parts.push(surname.as_str());
        }
        if let Some(suffix) = &self.suffix {
            parts.push(suffix.as_str());
        }
        if parts.is_empty() {
            return self.original.replace('/', "").trim().to_owned();
        }
        parts.join(" ")
    }

    /// `Surname, Given` — how a name index and an alphabetical list want it.
    #[must_use]
    pub fn sort_form(&self) -> String {
        match (&self.surname, &self.given) {
            (Some(surname), Some(given)) => format!("{surname}, {given}"),
            (Some(surname), None) => surname.clone(),
            (None, Some(given)) => given.clone(),
            (None, None) => self.display(),
        }
    }

    /// Whether the name carries nothing at all, which a `NAME` with an empty
    /// payload and no substructures does.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.given.is_none() && self.surname.is_none() && self.original.trim().is_empty()
    }
}

fn trimmed(value: &str) -> Option<String> {
    let trimmed = value.trim();
    (!trimmed.is_empty()).then(|| trimmed.to_owned())
}

/// A typed name in GEDCOM's slashed form.
///
/// The last word is read as the surname: `Ada Example` becomes
/// `Ada /Example/`. A name the caller already slashed is returned exactly as
/// typed, because the typist has said where the surname is and guessing over
/// them would be wrong.
#[must_use]
pub fn slashed(value: &str) -> String {
    let trimmed = value.trim();
    if trimmed.contains('/') {
        return trimmed.to_owned();
    }
    match trimmed.rsplit_once(' ') {
        Some((given, surname)) => format!("{given} /{surname}/"),
        None => format!("/{trimmed}/"),
    }
}

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

    #[test]
    fn the_slashed_form_splits_into_pieces() {
        let name = PersonalName::from_payload("Mira Elowen /North Quill/");

        assert_eq!(name.given.as_deref(), Some("Mira Elowen"));
        assert_eq!(name.surname.as_deref(), Some("North Quill"));
        assert_eq!(name.display(), "Mira Elowen North Quill");
        assert_eq!(name.sort_form(), "North Quill, Mira Elowen");
    }

    #[test]
    fn a_suffix_after_the_surname_is_kept() {
        let name = PersonalName::from_payload("Rowan /Suffix-Test/ Jr.");

        assert_eq!(name.suffix.as_deref(), Some("Jr."));
        assert_eq!(name.display(), "Rowan Suffix-Test Jr.");
    }

    #[test]
    fn a_name_with_no_slashes_is_not_guessed_at() {
        let name = PersonalName::from_payload("Unslashed Example");

        assert_eq!(name.given.as_deref(), Some("Unslashed Example"));
        assert_eq!(name.surname, None);
        assert_eq!(name.display(), "Unslashed Example");
    }

    #[test]
    fn an_unclosed_slash_still_yields_a_surname() {
        let name = PersonalName::from_payload("Rill /Unclosed-Test");

        assert_eq!(name.surname.as_deref(), Some("Unclosed-Test"));
    }

    #[test]
    fn substructures_override_only_the_piece_they_name() {
        let node = Node::with_value("NAME", "Mira Elowen /North Quill/")
            .child(Node::with_value("SURN", "Quill"))
            .child(Node::with_value("NICK", "Miri"))
            .child(Node::with_value("TYPE", "birth"));
        let name = PersonalName::from_node(&node);

        assert_eq!(name.given.as_deref(), Some("Mira Elowen"));
        assert_eq!(name.surname.as_deref(), Some("Quill"));
        assert_eq!(name.nickname.as_deref(), Some("Miri"));
        assert_eq!(name.kind.as_deref(), Some("birth"));
    }

    #[test]
    fn a_surname_particle_sorts_with_the_surname_and_shows_before_it() {
        let node = Node::with_value("NAME", "Wren /River-Test/")
            .child(Node::with_value("SPFX", "van"))
            .child(Node::with_value("NPFX", "Dr"));
        let name = PersonalName::from_node(&node);

        assert_eq!(name.display(), "Dr Wren van River-Test");
    }

    #[test]
    fn an_empty_name_is_recognized_rather_than_shown_as_blank() {
        assert!(PersonalName::from_payload("").is_empty());
        assert!(!PersonalName::from_payload("//").is_empty());
    }

    #[test]
    fn a_typed_name_gains_slashes_and_a_slashed_one_is_left_alone() {
        assert_eq!(slashed("Ada Example"), "Ada /Example/");
        assert_eq!(slashed("Madonna"), "/Madonna/");
        assert_eq!(slashed(" Ada /van Example/ "), "Ada /van Example/");
    }
}