use crate::{Document, GedcomError, GedcomErrorKind, Limits, Node, fault, is_tag, is_xref};
use std::collections::HashSet;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct NodeSpec {
#[cfg_attr(feature = "serde", serde(default))]
pub xref: Option<String>,
pub tag: String,
#[cfg_attr(feature = "serde", serde(default))]
pub value: Option<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub children: Vec<Self>,
}
impl NodeSpec {
#[must_use]
pub fn new(tag: impl Into<String>) -> Self {
Self {
tag: tag.into(),
..Self::default()
}
}
#[must_use]
pub fn with_value(tag: impl Into<String>, value: impl Into<String>) -> Self {
Self {
value: Some(value.into()),
..Self::new(tag)
}
}
#[must_use]
pub fn record(xref: impl Into<String>, tag: impl Into<String>) -> Self {
Self {
xref: Some(xref.into()),
..Self::new(tag)
}
}
pub fn into_node(self, limits: Limits) -> Result<Node, GedcomError> {
let mut structures = 0;
self.build(0, limits, &mut structures)
}
pub fn into_substructure(self, limits: Limits) -> Result<Node, GedcomError> {
let mut structures = 0;
self.build(1, limits, &mut structures)
}
fn build(
self,
level: u16,
limits: Limits,
structures: &mut usize,
) -> Result<Node, GedcomError> {
if level > limits.depth {
return Err(fault(
0,
GedcomErrorKind::Limit,
"the structure is nested deeper than the limit",
));
}
*structures += 1;
if *structures > limits.structures {
return Err(fault(
0,
GedcomErrorKind::Limit,
"the record has too many structures",
));
}
if !is_tag(&self.tag) {
return Err(fault(
0,
GedcomErrorKind::Syntax,
format!(
"{:?} is not a GEDCOM tag: ASCII letters, digits, or underscore",
self.tag
),
));
}
let mut node = match self.xref {
Some(xref) if level > 0 => {
return Err(fault(
0,
GedcomErrorKind::Syntax,
format!(
"{xref} is on a {} below level zero, where only records carry identifiers",
self.tag
),
));
}
Some(xref) if !is_xref(&xref) || xref.contains(' ') => {
return Err(fault(
0,
GedcomErrorKind::Syntax,
format!("{xref:?} is not a cross-reference identifier: the @I1@ shape"),
));
}
Some(xref) => Node::record(xref, self.tag),
None => Node::new(self.tag),
};
if let Some(value) = self.value.filter(|value| !value.is_empty()) {
node.set_logical_value(&value);
}
for child in self.children {
node.push(child.build(level + 1, limits, structures)?);
}
Ok(node)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct WrappedLine {
pub record: String,
pub path: Vec<usize>,
pub tag: String,
}
const SMALLEST_LINE_LIMIT: usize = 32;
pub const MAX_LINE_LENGTH: usize = 255;
const TERMINATOR_RESERVE: usize = 2;
fn segment_prefix_bytes(level: usize, xref: Option<&str>, tag: &str) -> usize {
level.to_string().len() + 1 + xref.map_or(0, |xref| xref.len() + 1) + tag.len() + 1
}
fn resplit_segment(
level: usize,
xref: Option<&str>,
tag: &str,
verbatim: Option<&str>,
value: &mut Option<String>,
old_concs: Vec<Node>,
max_line: usize,
) -> (Vec<Node>, bool) {
if verbatim.is_some() || old_concs.iter().any(|conc| conc.verbatim.is_some()) {
return (old_concs, false);
}
if value.as_deref().is_some_and(is_xref) {
return (old_concs, false);
}
let head_budget =
max_line.checked_sub(TERMINATOR_RESERVE + segment_prefix_bytes(level, xref, tag));
let conc_budget =
max_line.checked_sub(TERMINATOR_RESERVE + segment_prefix_bytes(level + 1, None, "CONC"));
let (Some(head_budget), Some(conc_budget)) = (head_budget, conc_budget) else {
return (old_concs, false);
};
if head_budget < 4 || conc_budget < 4 {
return (old_concs, false);
}
let head_fits = value.as_deref().map_or(0, str::len) <= head_budget;
let concs_fit = old_concs
.iter()
.all(|conc| conc.value.as_deref().map_or(0, str::len) <= conc_budget);
if head_fits && concs_fit {
return (old_concs, false);
}
let mut text = value.clone().unwrap_or_default();
for conc in &old_concs {
text.push_str(conc.value.as_deref().unwrap_or_default());
}
let mut pieces = cut(&text, head_budget, conc_budget).into_iter();
let head_piece = pieces.next().unwrap_or_default();
*value = (!head_piece.is_empty()).then(|| head_piece.to_owned());
let new_concs = pieces
.map(|piece| Node::with_value("CONC", piece))
.collect();
(new_concs, true)
}
impl Node {
pub fn fit_line_limit(&mut self, level: usize, max_line: usize) -> bool {
fit_node(self, level, max_line)
}
}
fn fit_node(node: &mut Node, level: usize, max_line: usize) -> bool {
let run_len = node
.children
.iter()
.take_while(|child| matches!(child.tag.as_str(), "CONT" | "CONC"))
.count();
let mut run: Vec<Node> = node.children.drain(0..run_len).collect();
let self_conc_end = run
.iter()
.position(|child| child.tag == "CONT")
.unwrap_or(run.len());
let self_concs: Vec<Node> = run.drain(0..self_conc_end).collect();
let xref = node.xref.clone();
let tag = node.tag.clone();
let verbatim = node.verbatim.clone();
let (mut output, mut changed) = resplit_segment(
level,
xref.as_deref(),
&tag,
verbatim.as_deref(),
&mut node.value,
self_concs,
max_line,
);
if changed {
node.verbatim = None;
}
let mut segments = run.into_iter().peekable();
while let Some(mut cont) = segments.next() {
let mut concs = Vec::new();
while segments.peek().is_some_and(|next| next.tag == "CONC") {
concs.push(segments.next().expect("peeked"));
}
let cont_verbatim = cont.verbatim.clone();
let (new_concs, seg_changed) = resplit_segment(
level + 1,
None,
"CONT",
cont_verbatim.as_deref(),
&mut cont.value,
concs,
max_line,
);
if seg_changed {
cont.verbatim = None;
}
changed |= seg_changed;
output.push(cont);
output.extend(new_concs);
}
let inserted = output.len();
node.children.splice(0..0, output);
for child in node.children.iter_mut().skip(inserted) {
changed |= fit_node(child, level + 1, max_line);
}
changed
}
impl Document {
pub fn append_structures(
&mut self,
record: &str,
path: &[usize],
structures: Vec<Node>,
) -> Result<(), GedcomError> {
if let Some(named) = structures.iter().find(|node| node.xref.is_some()) {
return Err(fault(
0,
GedcomErrorKind::Syntax,
format!(
"a {} below level zero cannot carry an identifier",
named.tag
),
));
}
let wanted = record.to_ascii_uppercase();
let owner = self
.records
.iter_mut()
.find(|node| {
node.xref.as_ref().map_or_else(
|| node.tag == record,
|xref| xref.to_ascii_uppercase() == wanted,
)
})
.ok_or_else(|| fault(0, GedcomErrorKind::Structure, format!("no record {record}")))?;
let target = owner.at_mut(path).ok_or_else(|| {
fault(
0,
GedcomErrorKind::Structure,
format!("no structure at {path:?} in {record}"),
)
})?;
target.children.extend(structures);
Ok(())
}
pub fn append_records(&mut self, records: Vec<Node>) -> Result<(), GedcomError> {
let mut seen: HashSet<String> = self.xref_index().into_keys().collect();
for record in &records {
if matches!(record.tag.as_str(), "HEAD" | "TRLR") {
return Err(fault(
0,
GedcomErrorKind::Structure,
format!(
"a document has one {}, and this one already does",
record.tag
),
));
}
if let Some(xref) = &record.xref
&& !seen.insert(xref.to_ascii_uppercase())
{
return Err(fault(
0,
GedcomErrorKind::Structure,
format!("the document already has a record {xref}"),
));
}
}
let at = self
.records
.iter()
.rposition(|record| record.tag == "TRLR")
.unwrap_or(self.records.len());
self.records.splice(at..at, records);
Ok(())
}
pub fn wrap_long_lines(
&mut self,
max_line_bytes: usize,
) -> Result<Vec<WrappedLine>, GedcomError> {
if self.is_version_7_or_later() {
return Err(fault(
0,
GedcomErrorKind::Structure,
"version 7 has no CONC and no line limit, so there is nothing to wrap",
));
}
if max_line_bytes < SMALLEST_LINE_LIMIT {
return Err(fault(
0,
GedcomErrorKind::Limit,
format!(
"a line limit under {SMALLEST_LINE_LIMIT} bytes leaves no room for a payload"
),
));
}
let budget = max_line_bytes - 1;
let mut wrapped = Vec::new();
for record in &mut self.records {
let owner = record.xref.clone().unwrap_or_else(|| record.tag.clone());
let mut path = Vec::new();
wrap_node(record, 0, budget, &owner, &mut path, &mut wrapped);
}
Ok(wrapped)
}
pub fn fit_line_limit(&mut self, xref: &str, max_line: usize) -> Result<bool, GedcomError> {
let record = self
.record_mut(xref)
.ok_or_else(|| fault(0, GedcomErrorKind::Structure, format!("no record {xref}")))?;
Ok(record.fit_line_limit(0, max_line))
}
}
fn wrap_node(
node: &mut Node,
level: u16,
budget: usize,
owner: &str,
path: &mut Vec<usize>,
wrapped: &mut Vec<WrappedLine>,
) {
if needs_wrapping(node, level, budget) && rewrap(node, level, budget) {
wrapped.push(WrappedLine {
record: owner.to_owned(),
path: path.clone(),
tag: node.tag.clone(),
});
}
for (index, child) in node.children.iter_mut().enumerate() {
if matches!(child.tag.as_str(), "CONT" | "CONC") {
continue;
}
path.push(index);
wrap_node(child, level + 1, budget, owner, path, wrapped);
path.pop();
}
}
fn prefix_bytes(level: u16, xref: Option<&str>, tag: &str) -> usize {
level.to_string().len() + 1 + xref.map_or(0, |xref| xref.len() + 1) + tag.len() + 1
}
fn line_bytes(node: &Node, level: u16) -> usize {
node.verbatim.as_deref().map_or_else(
|| {
prefix_bytes(level, node.xref.as_deref(), &node.tag) - 1
+ node.value.as_deref().map_or(0, |value| value.len() + 1)
},
str::len,
)
}
fn needs_wrapping(node: &Node, level: u16, budget: usize) -> bool {
line_bytes(node, level) > budget
|| node
.children
.iter()
.filter(|child| matches!(child.tag.as_str(), "CONT" | "CONC"))
.any(|child| line_bytes(child, level + 1) > budget)
}
fn rewrap(node: &mut Node, level: u16, budget: usize) -> bool {
let Some(first_room) = budget.checked_sub(prefix_bytes(level, node.xref.as_deref(), &node.tag))
else {
return false;
};
let continuation_room = budget.saturating_sub(prefix_bytes(level + 1, None, "CONC"));
if first_room < 4 || continuation_room < 4 {
return false;
}
let logical = node.logical_value();
let mut value = None;
let mut continuations = Vec::new();
for (index, line) in logical.split('\n').enumerate() {
let room = if index == 0 {
first_room
} else {
continuation_room
};
let mut pieces = cut(line, room, continuation_room).into_iter();
let head = pieces.next().filter(|piece| !piece.is_empty());
if index == 0 {
value = head.map(str::to_owned);
} else {
continuations.push(
head.map_or_else(|| Node::new("CONT"), |head| Node::with_value("CONT", head)),
);
}
continuations.extend(pieces.map(|piece| Node::with_value("CONC", piece)));
}
node.children
.retain(|child| !matches!(child.tag.as_str(), "CONT" | "CONC"));
node.set_value(value);
for (index, continuation) in continuations.into_iter().enumerate() {
node.children.insert(index, continuation);
}
true
}
fn cut(line: &str, first: usize, rest: usize) -> Vec<&str> {
let mut pieces = Vec::new();
let mut remaining = line;
let mut room = first;
while remaining.len() > room {
let at = best_cut(remaining, room);
pieces.push(&remaining[..at]);
remaining = &remaining[at..];
room = rest;
}
pieces.push(remaining);
pieces
}
fn best_cut(text: &str, room: usize) -> usize {
let widest = floor_boundary(text, room);
let lowest = (widest / 2).max(1);
let boundaries = || {
(lowest..=widest)
.rev()
.filter(|&at| text.is_char_boundary(at))
};
boundaries()
.find(|&at| !text[..at].ends_with(' ') && !text[at..].starts_with(' '))
.or_else(|| boundaries().find(|&at| !text[..at].ends_with(' ')))
.unwrap_or(widest)
}
fn floor_boundary(text: &str, index: usize) -> usize {
let mut at = index.min(text.len());
while !text.is_char_boundary(at) {
at -= 1;
}
at
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(tag: &str) -> NodeSpec {
NodeSpec::new(tag)
}
#[test]
fn a_description_becomes_the_node_it_describes() {
let mut record = NodeSpec::record("@I1@", "INDI");
let mut birth = spec("BIRT");
birth
.children
.push(NodeSpec::with_value("DATE", "5 JAN 1882"));
record
.children
.push(NodeSpec::with_value("NAME", "Ada /Example/"));
record.children.push(birth);
record
.children
.push(NodeSpec::with_value("NOTE", "one\ntwo"));
record.children.push(NodeSpec::with_value("SEX", ""));
let node = record.into_node(Limits::DEFAULT).expect("valid");
let text = Document {
records: vec![node],
}
.to_text();
assert_eq!(
text,
"0 @I1@ INDI\n1 NAME Ada /Example/\n1 BIRT\n2 DATE 5 JAN 1882\n1 NOTE one\n2 CONT two\n1 SEX\n"
);
}
#[test]
fn a_description_the_parser_would_not_read_back_is_refused() {
let refused = |spec: NodeSpec| spec.into_node(Limits::DEFAULT).expect_err("refused").kind();
assert_eq!(refused(spec("NA ME")), GedcomErrorKind::Syntax);
assert_eq!(refused(spec("")), GedcomErrorKind::Syntax);
assert_eq!(
refused(NodeSpec::record("I1", "INDI")),
GedcomErrorKind::Syntax
);
assert_eq!(
refused(NodeSpec::record("@I 1@", "INDI")),
GedcomErrorKind::Syntax
);
let mut nested = spec("INDI");
nested.children.push(NodeSpec::record("@X1@", "NOTE"));
assert_eq!(refused(nested), GedcomErrorKind::Syntax);
}
#[test]
fn a_description_is_bounded() {
let mut deep = spec("A");
for _ in 0..10 {
let mut parent = spec("A");
parent.children.push(deep);
deep = parent;
}
let shallow = Limits::DEFAULT.with_depth(5);
let error = deep.into_node(shallow).expect_err("too deep");
assert_eq!(error.kind(), GedcomErrorKind::Limit);
}
#[test]
fn substructures_join_the_header_or_a_record_and_nothing_else_moves() {
let input = "0 HEAD\n1 SOUR ODD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 NAME Ada /Example/\n0 TRLR\n";
let mut document = Document::parse(input).expect("parses");
document
.append_structures("HEAD", &[], vec![Node::with_value("SUBM", "@U1@")])
.expect("header");
document
.append_structures("@i1@", &[0], vec![Node::with_value("GIVN", "Ada")])
.expect("record");
assert_eq!(
document.to_text(),
"0 HEAD\n1 SOUR ODD\n1 GEDC\n2 VERS 5.5.1\n1 SUBM @U1@\n0 @I1@ INDI\n1 NAME Ada /Example/\n2 GIVN Ada\n0 TRLR\n"
);
let refused = |result: Result<(), GedcomError>| result.expect_err("refused").kind();
assert_eq!(
refused(document.append_structures("@I9@", &[], vec![Node::new("NOTE")])),
GedcomErrorKind::Structure
);
assert_eq!(
refused(document.append_structures("@I1@", &[7], vec![Node::new("NOTE")])),
GedcomErrorKind::Structure
);
assert_eq!(
refused(document.append_structures("@I1@", &[], vec![Node::record("@N1@", "NOTE")])),
GedcomErrorKind::Syntax
);
let spec = NodeSpec::record("@N1@", "NOTE");
assert!(spec.into_substructure(Limits::DEFAULT).is_err());
}
#[test]
fn records_go_before_the_trailer_all_or_none() {
let mut document = Document::new_v551("TEST");
document
.append_records(vec![
Node::record("@I1@", "INDI"),
Node::record("@F1@", "FAM"),
])
.expect("appended");
let tags: Vec<&str> = document.records.iter().map(Node::tag).collect();
assert_eq!(tags, ["HEAD", "INDI", "FAM", "TRLR"]);
let before = document.clone();
let repeated = document.append_records(vec![
Node::record("@I2@", "INDI"),
Node::record("@i1@", "INDI"),
]);
assert_eq!(
repeated.expect_err("a repeated identifier").kind(),
GedcomErrorKind::Structure
);
let twice = document.append_records(vec![
Node::record("@I3@", "INDI"),
Node::record("@I3@", "INDI"),
]);
assert!(twice.is_err());
assert!(document.append_records(vec![Node::new("TRLR")]).is_err());
assert_eq!(document, before, "a refused call adds nothing");
}
#[test]
fn long_lines_are_wrapped_at_characters_not_beside_spaces() {
let text = "Pointe-Claire, Île de Montréal, Québec ".repeat(12);
let mut document = Document::new_v551("TEST");
let record = Node::record("@N1@", "NOTE").child(Node::with_value("NOTE", text.trim_end()));
document.append_records(vec![record]).expect("appended");
let untouched = document.records[0].clone();
let wrapped = document.wrap_long_lines(80).expect("wrapped");
assert_eq!(
wrapped,
[WrappedLine {
record: "@N1@".into(),
path: vec![0],
tag: "NOTE".into()
}]
);
let written = document.to_text();
for line in written.lines() {
assert!(line.len() < 80, "{line:?}");
assert!(!line.ends_with(' '), "{line:?}");
}
let reread = Document::parse(&written).expect("reads back");
let note = reread
.record("@N1@")
.and_then(|note| note.first("NOTE"))
.expect("note");
assert_eq!(note.logical_value(), text.trim_end());
assert_eq!(
document.records[0], untouched,
"the header fits and is not touched"
);
}
#[test]
fn a_long_continuation_line_is_wrapped_in_place() {
let long = "word ".repeat(80);
let logical = format!("short\n{}\nend", long.trim_end());
let mut document = Document::new_v551("TEST");
let mut note = Node::record("@N1@", "NOTE");
note.set_logical_value(&logical);
let note = note.child(Node::with_value("SOUR", "@S1@"));
document.append_records(vec![note]).expect("appended");
document.wrap_long_lines(255).expect("wrapped");
let reread = Document::parse(&document.to_text()).expect("reads back");
let note = reread.record("@N1@").expect("note");
assert_eq!(note.logical_value(), logical);
assert_eq!(note.children.last().map(Node::tag), Some("SOUR"));
assert!(document.to_text().lines().all(|line| line.len() < 255));
}
#[test]
fn a_file_that_fits_is_left_exactly_as_it_was() {
let input = "0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 NAME Ada /Example/\n0 TRLR\n";
let mut document = Document::parse(input).expect("parses");
assert!(document.wrap_long_lines(255).expect("wrapped").is_empty());
assert_eq!(document.to_text(), input);
}
#[test]
fn version_seven_and_tiny_limits_are_refused() {
let mut seven = Document::new_v7();
assert_eq!(
seven.wrap_long_lines(255).expect_err("7.0").kind(),
GedcomErrorKind::Structure
);
let mut five = Document::new_v551("TEST");
assert_eq!(
five.wrap_long_lines(10).expect_err("tiny").kind(),
GedcomErrorKind::Limit
);
}
#[test]
fn cuts_fall_on_character_boundaries() {
let text = "ÅÅÅÅÅÅÅÅÅÅ";
for piece in cut(text, 5, 5) {
assert!(piece.len() <= 5);
}
assert_eq!(cut(text, 5, 5).concat(), text);
assert_eq!(cut("a b", 1, 1), ["a", " ", "b"]);
for piece in cut(&"x ".repeat(40), 9, 9).iter().rev().skip(1) {
assert!(!piece.ends_with(' '), "{piece:?}");
}
}
fn fits(node: &Node, max_line: usize) -> bool {
Document {
records: vec![node.clone()],
}
.to_text()
.lines()
.all(|line| line.len() < max_line)
}
#[test]
fn fit_line_limit_splits_a_long_edited_ascii_payload() {
let mut note = Node::record("@N1@", "NOTE");
let text = "word ".repeat(80);
note.set_logical_value(&text);
assert!(note.verbatim().is_none(), "an edited node has no verbatim");
assert!(note.fit_line_limit(0, MAX_LINE_LENGTH));
assert_eq!(note.logical_value(), text, "fold(split(x)) == x");
assert!(fits(¬e, MAX_LINE_LENGTH));
assert!(
note.all("CONC").count() > 0,
"the overflow became CONC children"
);
assert!(!note.fit_line_limit(0, MAX_LINE_LENGTH));
}
#[test]
fn fit_line_limit_splits_multibyte_text_on_character_boundaries() {
let mut note = Node::record("@N1@", "NOTE");
let text = "Pointe-Claire, Île de Montréal, Québec ".repeat(12);
note.set_logical_value(text.trim_end());
assert!(note.fit_line_limit(0, 80));
assert_eq!(note.logical_value(), text.trim_end());
assert!(fits(¬e, 80));
assert!(note.all("CONC").count() > 0);
}
#[test]
fn fit_line_limit_handles_a_space_at_every_candidate_boundary() {
let mut note = Node::record("@N1@", "NOTE");
let text = "x ".repeat(200);
note.set_logical_value(text.trim_end());
assert!(note.fit_line_limit(0, 40));
assert_eq!(note.logical_value(), text.trim_end());
assert!(fits(¬e, 40));
}
#[test]
fn fit_line_limit_splits_an_over_long_cont_line_in_place() {
let mut note = Node::record("@N1@", "NOTE");
let long_second_line = "b".repeat(80);
let logical = format!("short first line\n{long_second_line}\nlast line");
note.set_logical_value(&logical);
note.push(Node::with_value("SOUR", "@S1@"));
assert!(note.fit_line_limit(0, 40));
assert_eq!(note.logical_value(), logical);
assert!(fits(¬e, 40));
assert_eq!(note.children.last().map(Node::tag), Some("SOUR"));
let tags: Vec<&str> = note.children.iter().map(Node::tag).collect();
assert_eq!(tags.last(), Some(&"SOUR"));
assert_eq!(tags.iter().filter(|tag| **tag == "CONT").count(), 2);
assert!(tags.contains(&"CONC"));
}
#[test]
fn fit_line_limit_never_splits_a_pointer_payload() {
let long_pointer = format!("@{}@", "I".repeat(300));
let mut record = Node::record("@F1@", "FAM");
record.push(Node::with_value("HUSB", long_pointer.clone()));
let husb = record.first_mut("HUSB").expect("HUSB");
assert!(husb.pointer().is_some());
assert!(!husb.fit_line_limit(1, MAX_LINE_LENGTH));
assert_eq!(husb.value(), Some(long_pointer.as_str()));
assert!(husb.all("CONC").next().is_none());
}
#[test]
fn fit_line_limit_never_touches_a_node_still_carrying_its_verbatim_line() {
let long = "y".repeat(300);
let input = format!("0 HEAD\n0 @N1@ NOTE {long}\n0 TRLR\n");
let mut document = Document::parse(&input).expect("parses");
let note = document.record_mut("@N1@").expect("note");
assert!(note.verbatim().is_some(), "kept because of the odd spacing");
let before = note.clone();
assert!(!note.fit_line_limit(0, MAX_LINE_LENGTH));
assert_eq!(*note, before, "an untouched node is not rewritten");
assert_eq!(document.to_text(), input);
}
#[test]
fn fit_line_limit_recurses_past_an_untouched_verbatim_child() {
let long = "z".repeat(300);
let odd_note = format!("0 NOTE {long}\n");
let reparsed = Document::parse(&format!("0 HEAD\n{odd_note}0 TRLR\n"))
.expect("parses")
.records
.remove(1);
assert!(reparsed.verbatim().is_some());
let mut record = Node::record("@I1@", "INDI");
record.push(reparsed.clone());
let mut edited_name = Node::with_value("NAME", "x".repeat(300));
edited_name.set_logical_value(&"x".repeat(300));
record.push(edited_name);
assert!(record.fit_line_limit(0, MAX_LINE_LENGTH));
assert_eq!(
record.children[0], reparsed,
"the verbatim child is untouched"
);
assert!(
record.children[1].all("CONC").count() > 0,
"the edited sibling was still fitted"
);
}
#[test]
fn fit_line_limit_covers_a_record_with_an_xref() {
let mut record = Node::record("@I123456@", "INDI");
record.push(Node::with_value("NOTE", "n".repeat(260)));
assert!(record.fit_line_limit(0, MAX_LINE_LENGTH));
assert!(fits(&record, MAX_LINE_LENGTH));
let note = record.first("NOTE").expect("note");
assert_eq!(note.logical_value(), "n".repeat(260));
}
#[test]
fn document_fit_line_limit_reaches_one_record_by_xref() {
let mut document = Document::new_v551("TEST");
let mut note = Node::record("@N1@", "NOTE");
note.set_logical_value(&"word ".repeat(80));
document.append_records(vec![note]).expect("appended");
assert!(
document
.fit_line_limit("@N1@", MAX_LINE_LENGTH)
.expect("record exists")
);
assert!(
document
.to_text()
.lines()
.all(|line| line.len() < MAX_LINE_LENGTH)
);
let missing = document.fit_line_limit("@N9@", MAX_LINE_LENGTH);
assert_eq!(
missing.expect_err("no such record").kind(),
GedcomErrorKind::Structure
);
}
}