use crate::dates::GedcomDate;
use crate::view::{FamilyView, IndividualView, record_title};
use crate::{Document, Node};
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum Severity {
Contradiction,
Question,
Missing,
}
impl Severity {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Contradiction => "contradiction",
Self::Question => "worth a look",
Self::Missing => "not recorded",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct Finding {
pub record: String,
pub check: &'static str,
pub severity: Severity,
pub message: String,
}
const LONGEST_LIFE: i32 = 125;
const YOUNGEST_MARRIAGE: i32 = 13;
const OLDEST_MOTHER: i32 = 55;
const OLDEST_FATHER: i32 = 80;
const YOUNGEST_PARENT: i32 = 12;
#[derive(Clone, Copy, Default)]
struct Life {
born: Option<i32>,
died: Option<i32>,
buried: Option<i32>,
}
#[must_use]
pub fn inspect(document: &Document) -> Vec<Finding> {
let people: Vec<(&Node, IndividualView)> = document
.records_of("INDI")
.map(|record| (record, IndividualView::from_node(record)))
.collect();
let lives: HashMap<String, Life> = people
.iter()
.filter_map(|(record, view)| {
record
.xref
.as_ref()
.map(|xref| (xref.to_ascii_uppercase(), life_of(view)))
})
.collect();
let mut found = Vec::new();
for (record, view) in &people {
inspect_person(record, view, &lives, &mut found);
}
for record in document.records_of("FAM") {
inspect_family(record, &lives, &mut found);
}
found
}
fn year_of(view: &IndividualView, tag: &str) -> Option<i32> {
view.events
.iter()
.find(|event| event.tag == tag)
.and_then(|event| event.date.as_deref())
.and_then(|payload| GedcomDate::parse(payload).year_hint())
}
fn life_of(view: &IndividualView) -> Life {
Life {
born: year_of(view, "BIRT"),
died: year_of(view, "DEAT"),
buried: year_of(view, "BURI"),
}
}
fn add(
found: &mut Vec<Finding>,
record: &Node,
check: &'static str,
severity: Severity,
message: String,
) {
found.push(Finding {
record: record.xref.clone().unwrap_or_else(|| record.tag.clone()),
check,
severity,
message,
});
}
fn inspect_person(
record: &Node,
view: &IndividualView,
lives: &HashMap<String, Life>,
found: &mut Vec<Finding>,
) {
let life = lives
.get(&record.xref.clone().unwrap_or_default().to_ascii_uppercase())
.copied()
.unwrap_or_default();
if let (Some(born), Some(died)) = (life.born, life.died) {
if died < born {
add(
found,
record,
"died-before-born",
Severity::Contradiction,
format!("born {born} and died {died}"),
);
} else if died - born > LONGEST_LIFE {
add(
found,
record,
"improbable-lifespan",
Severity::Question,
format!(
"born {born} and died {died}, which is {} years",
died - born
),
);
}
}
if let (Some(died), Some(buried)) = (life.died, life.buried)
&& buried < died
{
add(
found,
record,
"buried-before-died",
Severity::Contradiction,
format!("died {died} and buried {buried}"),
);
}
if let Some(died) = life.died {
for event in &view.events {
if matches!(
event.tag.as_str(),
"DEAT" | "BURI" | "CREM" | "PROB" | "WILL"
) {
continue;
}
let Some(year) = event
.date
.as_deref()
.and_then(|payload| GedcomDate::parse(payload).year_hint())
else {
continue;
};
if year > died {
add(
found,
record,
"event-after-death",
Severity::Contradiction,
format!("died {died}, and {} is dated {year}", event.label),
);
}
}
}
let cited = record.walk().any(|node| node.tag == "SOUR");
if !cited {
add(
found,
record,
"no-source",
Severity::Missing,
"nothing on this record cites a source".to_owned(),
);
}
}
fn inspect_family(record: &Node, lives: &HashMap<String, Life>, found: &mut Vec<Finding>) {
let family = FamilyView::from_node(record);
let married = record
.first("MARR")
.and_then(|event| event.value_of("DATE"))
.and_then(|payload| GedcomDate::parse(&payload).year_hint());
for parent in family.partners() {
let Some(parent_life) = lives.get(&parent.to_ascii_uppercase()).copied() else {
continue;
};
inspect_marriage(record, married, parent_life, found);
let mother = record
.first("WIFE")
.and_then(Node::pointer)
.is_some_and(|wife| wife.eq_ignore_ascii_case(&parent));
for child in &family.children {
let Some(child_life) = lives.get(&child.to_ascii_uppercase()).copied() else {
continue;
};
inspect_parenthood(record, parent_life, child_life, mother, found);
}
}
let mut seen: Vec<String> = Vec::new();
for child in &family.children {
let key = child.to_ascii_uppercase();
if seen.contains(&key) {
add(
found,
record,
"child-listed-twice",
Severity::Question,
format!("{child} is listed as a child of this family more than once"),
);
} else {
seen.push(key);
}
}
if family.partners().is_empty() && family.children.is_empty() {
add(
found,
record,
"empty-family",
Severity::Question,
"this family records nobody at all".to_owned(),
);
}
}
fn inspect_marriage(record: &Node, married: Option<i32>, parent: Life, found: &mut Vec<Finding>) {
let (Some(married), Some(born)) = (married, parent.born) else {
return;
};
let age = married - born;
if age < 0 {
add(
found,
record,
"married-before-born",
Severity::Contradiction,
format!("a marriage in {married} for somebody born {born}"),
);
} else if age < YOUNGEST_MARRIAGE {
add(
found,
record,
"married-as-a-child",
Severity::Question,
format!("married in {married} at {age}, born {born}"),
);
}
}
fn inspect_parenthood(
record: &Node,
parent: Life,
child: Life,
mother: bool,
found: &mut Vec<Finding>,
) {
let (Some(parent_born), Some(child_born)) = (parent.born, child.born) else {
return;
};
let age = child_born - parent_born;
if age < YOUNGEST_PARENT {
let how = if age < 0 { "before" } else { "at" };
add(
found,
record,
"parent-too-young",
Severity::Contradiction,
format!(
"a parent born {parent_born} with a child born {child_born}, {how} {} years old",
age.abs()
),
);
return;
}
let limit = if mother { OLDEST_MOTHER } else { OLDEST_FATHER };
if age > limit {
add(
found,
record,
"parent-too-old",
Severity::Question,
format!("a parent born {parent_born} with a child born {child_born}, at {age}"),
);
}
if let (true, Some(parent_died)) = (mother, parent.died)
&& child_born > parent_died
{
add(
found,
record,
"child-born-after-mother-died",
Severity::Contradiction,
format!("a mother who died {parent_died} with a child born {child_born}"),
);
}
}
#[must_use]
pub fn heading(document: &Document, xref: &str) -> String {
document
.record(xref)
.map_or_else(|| xref.to_owned(), record_title)
}
#[cfg(test)]
mod tests {
use super::*;
fn findings(text: &str) -> Vec<Finding> {
inspect(&Document::parse(text).expect("parse"))
}
fn checks(found: &[Finding]) -> Vec<&str> {
found.iter().map(|finding| finding.check).collect()
}
#[test]
fn a_life_that_runs_backwards_is_a_contradiction() {
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 BIRT\n\
2 DATE 1900\n\
1 DEAT\n\
2 DATE 1880\n\
0 TRLR\n",
);
let died = found
.iter()
.find(|finding| finding.check == "died-before-born")
.expect("the contradiction");
assert_eq!(died.severity, Severity::Contradiction);
assert!(died.message.contains("1900") && died.message.contains("1880"));
}
#[test]
fn an_event_after_the_funeral_is_reported_and_probate_is_not() {
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 DEAT\n\
2 DATE 1920\n\
1 RESI\n\
2 DATE 1925\n\
1 PROB\n\
2 DATE 1921\n\
0 TRLR\n",
);
let after: Vec<&Finding> = found
.iter()
.filter(|finding| finding.check == "event-after-death")
.collect();
assert_eq!(after.len(), 1, "{found:?}");
assert!(
after[0].message.contains("Residence"),
"{}",
after[0].message
);
}
#[test]
fn a_parent_younger_than_their_child_is_a_contradiction_and_an_old_one_is_a_question() {
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 SEX F\n\
1 BIRT\n\
2 DATE 1900\n\
1 FAMS @F1@\n\
0 @I2@ INDI\n\
1 BIRT\n\
2 DATE 1905\n\
1 FAMC @F1@\n\
0 @F1@ FAM\n\
1 WIFE @I1@\n\
1 CHIL @I2@\n\
0 TRLR\n",
);
assert!(checks(&found).contains(&"parent-too-young"), "{found:?}");
let older = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 BIRT\n\
2 DATE 1900\n\
1 FAMS @F1@\n\
0 @I2@ INDI\n\
1 BIRT\n\
2 DATE 1960\n\
1 FAMC @F1@\n\
0 @F1@ FAM\n\
1 WIFE @I1@\n\
1 CHIL @I2@\n\
0 TRLR\n",
);
let old = older
.iter()
.find(|finding| finding.check == "parent-too-old")
.expect("a question");
assert_eq!(old.severity, Severity::Question);
assert!(old.message.contains("60"), "{}", old.message);
}
#[test]
fn a_marriage_before_a_birth_and_one_in_childhood_are_told_apart() {
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 BIRT\n\
2 DATE 1900\n\
1 FAMS @F1@\n\
0 @I2@ INDI\n\
1 BIRT\n\
2 DATE 1899\n\
1 FAMS @F1@\n\
0 @F1@ FAM\n\
1 HUSB @I1@\n\
1 WIFE @I2@\n\
1 MARR\n\
2 DATE 1890\n\
0 TRLR\n",
);
assert_eq!(
checks(&found)
.iter()
.filter(|check| **check == "married-before-born")
.count(),
2,
"both partners were married before they were born: {found:?}"
);
}
#[test]
fn a_person_nothing_cites_is_the_finding_a_researcher_wants() {
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 NAME Cited /Sampleperson/\n\
1 BIRT\n\
2 DATE 1900\n\
3 SOUR @S1@\n\
0 @I2@ INDI\n\
1 NAME Unsourced /Sampleperson/\n\
0 @S1@ SOUR\n\
1 TITL A register\n\
0 TRLR\n",
);
let missing: Vec<&Finding> = found
.iter()
.filter(|finding| finding.check == "no-source")
.collect();
assert_eq!(missing.len(), 1, "{found:?}");
assert_eq!(missing[0].record, "@I2@");
assert_eq!(missing[0].severity, Severity::Missing);
}
#[test]
fn an_approximate_date_is_not_turned_into_a_contradiction() {
let unreadable = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 BIRT\n\
2 DATE Infant\n\
1 DEAT\n\
2 DATE Unknown\n\
0 TRLR\n",
);
assert!(
!checks(&unreadable).contains(&"died-before-born"),
"{unreadable:?}"
);
}
#[test]
fn a_clean_record_produces_nothing_but_what_is_actually_missing() {
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 NAME Ordinary /Sampleperson/\n\
1 BIRT\n\
2 DATE 1900\n\
2 SOUR @S1@\n\
1 DEAT\n\
2 DATE 1975\n\
0 @S1@ SOUR\n\
1 TITL A register\n\
0 TRLR\n",
);
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn a_child_listed_twice_is_one_child_and_a_duplicate_line() {
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 FAMC @F1@\n\
0 @F1@ FAM\n\
1 CHIL @I1@\n\
1 CHIL @I1@\n\
0 TRLR\n",
);
assert!(checks(&found).contains(&"child-listed-twice"), "{found:?}");
}
}