use crate::dates::{Calendar, GedcomDate};
use crate::view::{FamilyView, IndividualView, record_title};
use crate::{Document, Node};
use std::collections::HashMap;
use std::fmt;
#[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, Debug, Eq, PartialEq)]
struct Years {
earliest: Option<i32>,
latest: Option<i32>,
}
impl Years {
fn of(payload: &str) -> Option<Self> {
let date = GedcomDate::parse(payload);
if !date.is_parsed() || !matches!(date.calendar, Calendar::Gregorian | Calendar::Julian) {
return None;
}
let mut years = Self {
earliest: date.earliest.map(|point| point.year),
latest: date.latest.map(|point| point.year),
};
if let (Some(start), Some(end)) = (years.earliest, years.latest)
&& start > end
{
years = Self {
earliest: Some(end),
latest: Some(start),
};
}
(years.earliest.is_some() || years.latest.is_some()).then_some(years)
}
const fn wholly_before(self, other: Self) -> bool {
matches!((self.latest, other.earliest), (Some(end), Some(start)) if end < start)
}
fn gap_to(self, later: Self) -> (Option<i32>, Option<i32>) {
let least = later
.earliest
.zip(self.latest)
.map(|(end, start)| end - start);
let most = later
.latest
.zip(self.earliest)
.map(|(end, start)| end - start);
(least, most)
}
}
impl fmt::Display for Years {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match (self.earliest, self.latest) {
(Some(start), Some(end)) if start == end => write!(formatter, "{start}"),
(Some(start), Some(end)) => write!(formatter, "between {start} and {end}"),
(None, Some(end)) => write!(formatter, "before {end}"),
(Some(start), None) => write!(formatter, "after {start}"),
(None, None) => formatter.write_str("undated"),
}
}
}
#[derive(Clone, Copy, Default)]
struct Life {
born: Option<Years>,
died: Option<Years>,
buried: Option<Years>,
}
#[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<Years> {
view.events
.iter()
.find(|event| event.tag == tag)
.and_then(|event| event.date.as_deref())
.and_then(Years::of)
}
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.wholly_before(born) {
add(
found,
record,
"died-before-born",
Severity::Contradiction,
format!("born {born} and died {died}"),
);
} else if let (Some(shortest), _) = born.gap_to(died)
&& shortest > LONGEST_LIFE
{
add(
found,
record,
"improbable-lifespan",
Severity::Question,
format!(
"born {born} and died {died}, which is {}{shortest} years",
if born.gap_to(died).1 == Some(shortest) {
""
} else {
"at least "
}
),
);
}
}
if let (Some(died), Some(buried)) = (life.died, life.buried)
&& buried.wholly_before(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(),
"BIRT" | "DEAT" | "BURI" | "CREM" | "PROB" | "WILL"
) {
continue;
}
let Some(year) = event.date.as_deref().and_then(Years::of) else {
continue;
};
if died.wholly_before(year) {
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| Years::of(&payload));
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<Years>, parent: Life, found: &mut Vec<Finding>) {
let (Some(married), Some(born)) = (married, parent.born) else {
return;
};
if married.wholly_before(born) {
add(
found,
record,
"married-before-born",
Severity::Contradiction,
format!("a marriage {} for somebody born {born}", in_year(married)),
);
} else if let (_, Some(oldest)) = born.gap_to(married)
&& oldest < YOUNGEST_MARRIAGE
{
add(
found,
record,
"married-as-a-child",
Severity::Question,
format!(
"married {} {}, born {born}",
in_year(married),
age(born, married)
),
);
}
}
fn in_year(years: Years) -> String {
match (years.earliest, years.latest) {
(Some(start), Some(end)) if start == end => format!("in {start}"),
_ => years.to_string(),
}
}
fn age(from: Years, to: Years) -> String {
match from.gap_to(to) {
(Some(least), Some(most)) if least == most => format!("at {least}"),
(Some(least), Some(most)) => format!("at {least} to {most}"),
(Some(least), None) => format!("at least {least}"),
(None, Some(most)) => format!("at most {most}"),
(None, None) => "at an unknown age".to_owned(),
}
}
fn inspect_parenthood(
record: &Node,
parent: Life,
child: Life,
mother: bool,
found: &mut Vec<Finding>,
) {
let Some(child_born) = child.born else {
return;
};
if let (true, Some(parent_died)) = (mother, parent.died)
&& parent_died.wholly_before(child_born)
{
add(
found,
record,
"child-born-after-mother-died",
Severity::Contradiction,
format!("a mother who died {parent_died} with a child born {child_born}"),
);
}
let Some(parent_born) = parent.born else {
return;
};
let (youngest, oldest) = parent_born.gap_to(child_born);
if let Some(oldest) = oldest
&& oldest < YOUNGEST_PARENT
{
let said = if oldest < 0 {
format!("{} years before the parent was born", oldest.abs())
} else if youngest == Some(oldest) {
format!("at {oldest} years old")
} else {
format!("at most {oldest} years old")
};
add(
found,
record,
"parent-too-young",
Severity::Contradiction,
format!("a parent born {parent_born} with a child born {child_born}, {said}"),
);
return;
}
let limit = if mother { OLDEST_MOTHER } else { OLDEST_FATHER };
if let Some(youngest) = youngest
&& youngest > limit
{
add(
found,
record,
"parent-too-old",
Severity::Question,
format!(
"a parent born {parent_born} with a child born {child_born}, {}",
age(parent_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_year_mentioned_in_an_unreadable_date_is_not_a_death_year() {
let found = findings(
"0 HEAD\n0 @I1@ INDI\n1 BIRT\n2 DATE 1807\n1 DEAT\n2 DATE WFT Est 1804-1911\n0 TRLR\n",
);
assert!(
!checks(&found).contains(&"died-before-born")
&& !checks(&found).contains(&"event-after-death"),
"{found:?}"
);
}
#[test]
fn a_bounded_date_contradicts_only_when_no_year_it_allows_could_be_true() {
let found =
findings("0 HEAD\n0 @I1@ INDI\n1 BIRT\n2 DATE 1807\n1 DEAT\n2 DATE BEF 1811\n0 TRLR\n");
assert!(!checks(&found).contains(&"died-before-born"), "{found:?}");
let found =
findings("0 HEAD\n0 @I1@ INDI\n1 BIRT\n2 DATE 1807\n1 DEAT\n2 DATE BEF 1805\n0 TRLR\n");
let died = found
.iter()
.find(|finding| finding.check == "died-before-born")
.expect("a contradiction");
assert_eq!(died.message, "born 1807 and died before 1805");
let found = findings(
"0 HEAD\n0 @I1@ INDI\n1 BIRT\n2 DATE BET 1800 AND 1810\n1 DEAT\n2 DATE 1805\n0 TRLR\n",
);
assert!(!checks(&found).contains(&"died-before-born"), "{found:?}");
}
#[test]
fn a_bounded_marriage_says_its_age_once() {
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 BIRT\n\
2 DATE 1802\n\
0 @F1@ FAM\n\
1 HUSB @I1@\n\
1 MARR\n\
2 DATE BEF 1811\n\
0 TRLR\n",
);
let child = found
.iter()
.find(|finding| finding.check == "married-as-a-child")
.expect("married as a child");
assert_eq!(child.message, "married before 1811 at most 9, born 1802");
}
#[test]
fn another_calendar_and_a_backwards_range_are_not_misread() {
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 BIRT\n\
2 DATE @#DHEBREW@ 5620\n\
1 DEAT\n\
2 DATE 1900\n\
0 TRLR\n",
);
assert!(!checks(&found).contains(&"died-before-born"), "{found:?}");
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 BIRT\n\
2 DATE BET 1910 AND 1900\n\
1 DEAT\n\
2 DATE 1905\n\
0 TRLR\n",
);
assert!(!checks(&found).contains(&"died-before-born"), "{found:?}");
}
#[test]
fn a_child_born_before_its_parent_says_so_plainly() {
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 BIRT\n\
2 DATE BET 1850 AND 1855\n\
0 @I2@ INDI\n\
1 BIRT\n\
2 DATE BET 1840 AND 1845\n\
0 @F1@ FAM\n\
1 HUSB @I1@\n\
1 CHIL @I2@\n\
0 TRLR\n",
);
let young = found
.iter()
.find(|finding| finding.check == "parent-too-young")
.expect("born before the parent");
assert!(
young
.message
.ends_with("5 years before the parent was born"),
"{}",
young.message
);
}
#[test]
fn a_mother_dead_before_the_birth_needs_no_birth_year_of_her_own() {
let found = findings(
"0 HEAD\n\
0 @I1@ INDI\n\
1 DEAT\n\
2 DATE 1850\n\
0 @I2@ INDI\n\
1 BIRT\n\
2 DATE 1860\n\
0 @F1@ FAM\n\
1 WIFE @I1@\n\
1 CHIL @I2@\n\
0 TRLR\n",
);
assert!(
checks(&found).contains(&"child-born-after-mother-died"),
"{found:?}"
);
}
#[test]
fn a_parent_with_a_bounded_birth_is_too_young_only_when_every_age_is() {
let family = |parent: &str, child: &str| {
findings(&format!(
"0 HEAD\n0 @I1@ INDI\n1 BIRT\n2 DATE {parent}\n0 @I2@ INDI\n1 BIRT\n2 DATE {child}\n\
0 @F1@ FAM\n1 HUSB @I1@\n1 CHIL @I2@\n0 TRLR\n"
))
};
assert!(!checks(&family("AFT 1850", "1870")).contains(&"parent-too-young"));
let found = family("BET 1860 AND 1865", "1870");
let young = found
.iter()
.find(|finding| finding.check == "parent-too-young")
.expect("too young at every allowed age");
assert_eq!(
young.message,
"a parent born between 1860 and 1865 with a child born 1870, at most 10 years old"
);
}
#[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:?}");
}
}