From dd02610317f3c8aaba73618e6af07d0cde034886 Mon Sep 17 00:00:00 2001 From: Adam Israel Date: Mon, 8 Dec 2025 10:07:36 -0500 Subject: [PATCH] Implement full CHANGE_DATE structure support with DATE, TIME, and NOTE fields Add complete GEDCOM 5.5.1 CHANGE_DATE structure support across all record types. Previously, CHANGE_DATE was either partially implemented (only capturing DATE) or completely skipped (SourceRecord and NoteRecord). This commit implements full support including DATE, TIME, and NOTE fields as specified in GEDCOM 5.5.1. Changes: - Add new ChangeDate struct with date, time, and notes fields - Update all 7 record types to use ChangeDate instead of Option: * Individual records * Family records * Submitter records * Repository records * Multimedia records * Source records (previously skipping CHAN entirely) * Note records (previously skipping CHAN entirely) - Add comprehensive test coverage for all CHANGE_DATE features - Update ROADMAP.md to reflect full CHANGE_DATE support Breaking change: change_date field type changed from Option to Option across all record types. This is acceptable as there are no users yet. All 266 tests passing. No clippy warnings. --- docs/ROADMAP.md | 14 +- src/main.rs | 18 +- src/types/change_date.rs | 256 +++++++++++++++++++++++++++++ src/types/family.rs | 56 +++++-- src/types/individual/individual.rs | 59 ++++--- src/types/mod.rs | 2 + src/types/multimedia_record.rs | 28 ++-- src/types/note_record.rs | 26 ++- src/types/repository_record.rs | 33 ++-- src/types/source_record.rs | 20 +-- src/types/submitter.rs | 33 ++-- 11 files changed, 416 insertions(+), 129 deletions(-) create mode 100644 src/types/change_date.rs diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 92e2a41..d72e32b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -63,7 +63,7 @@ This document tracks the implementation status of GEDCOM 5.5.1 specification fea - [x] REFN (supports multiple) - [x] TYPE - [x] RIN -- [x] CHANGE_DATE (basic parsing - stores DATE value, skips TIME and other structure) +- [x] CHANGE_DATE (full support: DATE, TIME, NOTE) - [x] NOTE_STRUCTURE - [x] SOURCE_CITATION - [x] MULTIMEDIA_LINK @@ -124,7 +124,7 @@ This document tracks the implementation status of GEDCOM 5.5.1 specification fea - [x] TYPE - [x] RIN (automated record ID) - [x] RESN (restriction notice) -- [x] CHANGE_DATE (CHAN - stores DATE value) +- [x] CHANGE_DATE (full support: DATE, TIME, NOTE) - [x] NOTE_STRUCTURE (supports multiple notes and note references) - [x] SOURCE_CITATION (supports multiple citations) - [x] MULTIMEDIA_LINK (supports multiple object references) @@ -143,7 +143,7 @@ This document tracks the implementation status of GEDCOM 5.5.1 specification fea - [x] RIN - [x] NOTE_STRUCTURE (supports multiple notes and note references) - [x] SOURCE_CITATION -- [ ] CHANGE_DATE (basic parsing, skips structure) +- [x] CHANGE_DATE (full support: DATE, TIME, NOTE) ### NOTE_RECORD @@ -154,7 +154,7 @@ This document tracks the implementation status of GEDCOM 5.5.1 specification fea - [x] REFN (supports multiple) - [x] TYPE - [x] RIN -- [ ] CHANGE_DATE (basic parsing, skips structure) +- [x] CHANGE_DATE (full support: DATE, TIME, NOTE) ### REPOSITORY_RECORD @@ -167,7 +167,7 @@ This document tracks the implementation status of GEDCOM 5.5.1 specification fea - [x] REFN (supports multiple) - [x] TYPE - [x] RIN -- [x] CHANGE_DATE (basic parsing - stores DATE value, skips TIME and other structure) +- [x] CHANGE_DATE (full support: DATE, TIME, NOTE) ### SOURCE_RECORD @@ -189,7 +189,7 @@ This document tracks the implementation status of GEDCOM 5.5.1 specification fea - [x] REFN (supports multiple) - [x] TYPE - [x] RIN -- [ ] CHANGE_DATE (basic parsing, skips structure) +- [x] CHANGE_DATE (full support: DATE, TIME, NOTE) - [x] NOTE_STRUCTURE (supports multiple notes) - [x] MULTIMEDIA_LINK (OBJE references) @@ -205,7 +205,7 @@ This document tracks the implementation status of GEDCOM 5.5.1 specification fea - [x] RFN (registered RFN) - [x] RIN (automated record ID) - [x] NOTE_STRUCTURE (supports multiple notes and note references) -- [x] CHANGE_DATE (basic parsing - stores DATE value, skips TIME and other structure) +- [x] CHANGE_DATE (full support: DATE, TIME, NOTE) ## Priority Features diff --git a/src/main.rs b/src/main.rs index 3d72b75..3e31f34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -226,13 +226,21 @@ mod tests { // Test @F2@ - has CHANGE_DATE let f2 = &gedcom.families[1]; assert_eq!(f2.xref.to_string(), "@F2@"); - assert_eq!(f2.change_date, Some("13 JUN 2000".to_string())); + assert!(f2.change_date.is_some()); + assert_eq!( + f2.change_date.as_ref().unwrap().date, + Some("13 JUN 2000".to_string()) + ); assert_eq!(f2.automated_record_id, Some("2".to_string())); // Test @F3@ - has CHANGE_DATE let f3 = &gedcom.families[2]; assert_eq!(f3.xref.to_string(), "@F3@"); - assert_eq!(f3.change_date, Some("13 JUN 2000".to_string())); + assert!(f3.change_date.is_some()); + assert_eq!( + f3.change_date.as_ref().unwrap().date, + Some("13 JUN 2000".to_string()) + ); assert_eq!(f3.automated_record_id, Some("3".to_string())); } @@ -276,7 +284,11 @@ mod tests { assert_eq!(subm.automated_record_id.as_deref(), Some("1")); // Check change date - assert_eq!(subm.change_date.as_deref(), Some("7 SEP 2000")); + assert!(subm.change_date.is_some()); + assert_eq!( + subm.change_date.as_ref().unwrap().date, + Some("7 SEP 2000".to_string()) + ); // Check automated record ID // (Duplicate assertion removed) diff --git a/src/types/change_date.rs b/src/types/change_date.rs new file mode 100644 index 0000000..f8a7438 --- /dev/null +++ b/src/types/change_date.rs @@ -0,0 +1,256 @@ +use super::{Line, Note}; +use winnow::prelude::*; + +// CHANGE_DATE:= +// +// n CHAN {0:1} +// +1 DATE {1:1} +// +2 TIME {0:1} +// +1 NOTE {0:M} + +/// Represents a CHANGE_DATE structure in GEDCOM +/// +/// This structure records when a record was last changed, including +/// the date, optional time, and optional notes about the change. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ChangeDate { + /// Date of the change (required if CHAN present) + pub date: Option, + + /// Time of the change (format: HH:MM:SS or HH:MM:SS.fraction) + pub time: Option, + + /// Notes about the change + pub notes: Vec, +} + +impl ChangeDate { + /// Parse a CHANGE_DATE structure (CHAN tag) + /// + /// Expects input positioned at the CHAN line and consumes the entire structure. + pub fn parse(input: &mut &str) -> PResult { + let mut change_date = ChangeDate::default(); + + // Parse the CHAN line + let Ok(chan_line) = Line::peek(input) else { + return Ok(change_date); + }; + + if chan_line.tag != "CHAN" { + return Ok(change_date); + } + + let chan_level = chan_line.level; + let _ = Line::parse(input); + + // Parse child tags (DATE, TIME, NOTE) + while let Ok(line) = Line::peek(input) { + // Stop if we've gone back to the same level or higher + if line.level <= chan_level { + break; + } + + let mut consume = true; + + match line.tag { + "DATE" if line.level == chan_level + 1 => { + change_date.date = Some(line.value.to_string()); + + // After parsing DATE, check for TIME at the next level + let _ = Line::parse(input); + consume = false; + + if let Ok(time_line) = Line::peek(input) { + if time_line.tag == "TIME" && time_line.level == chan_level + 2 { + change_date.time = Some(time_line.value.to_string()); + let _ = Line::parse(input); + } + } + } + "NOTE" if line.level == chan_level + 1 => { + if let Ok(note) = Note::parse(input) { + change_date.notes.push(note); + } + consume = false; + } + _ => { + // Unknown tag - skip it + } + } + + if consume { + let _ = Line::parse(input); + } + } + + Ok(change_date) + } +} + +#[allow(clippy::unwrap_used)] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_change_date_with_date_only() { + let data = vec!["1 CHAN", "2 DATE 13 JUN 2000"].join("\n"); + + let mut input = data.as_str(); + let change_date = ChangeDate::parse(&mut input).unwrap(); + + assert_eq!(change_date.date, Some("13 JUN 2000".to_string())); + assert_eq!(change_date.time, None); + assert_eq!(change_date.notes.len(), 0); + } + + #[test] + fn test_change_date_with_date_and_time() { + let data = vec!["1 CHAN", "2 DATE 7 SEP 2000", "3 TIME 8:35:36"].join("\n"); + + let mut input = data.as_str(); + let change_date = ChangeDate::parse(&mut input).unwrap(); + + assert_eq!(change_date.date, Some("7 SEP 2000".to_string())); + assert_eq!(change_date.time, Some("8:35:36".to_string())); + assert_eq!(change_date.notes.len(), 0); + } + + #[test] + fn test_change_date_with_date_time_and_note() { + let data = vec![ + "1 CHAN", + "2 DATE 17 Feb 2003", + "3 TIME 9:55:13", + "2 NOTE Updated to fix typo", + ] + .join("\n"); + + let mut input = data.as_str(); + let change_date = ChangeDate::parse(&mut input).unwrap(); + + assert_eq!(change_date.date, Some("17 Feb 2003".to_string())); + assert_eq!(change_date.time, Some("9:55:13".to_string())); + assert_eq!(change_date.notes.len(), 1); + assert_eq!( + change_date.notes[0].note, + Some("Updated to fix typo".to_string()) + ); + } + + #[test] + fn test_change_date_with_multiple_notes() { + let data = vec![ + "1 CHAN", + "2 DATE 11 Jan 2001", + "3 TIME 16:00:06", + "2 NOTE First change note", + "2 NOTE Second change note", + ] + .join("\n"); + + let mut input = data.as_str(); + let change_date = ChangeDate::parse(&mut input).unwrap(); + + assert_eq!(change_date.date, Some("11 Jan 2001".to_string())); + assert_eq!(change_date.time, Some("16:00:06".to_string())); + assert_eq!(change_date.notes.len(), 2); + assert_eq!( + change_date.notes[0].note, + Some("First change note".to_string()) + ); + assert_eq!( + change_date.notes[1].note, + Some("Second change note".to_string()) + ); + } + + #[test] + fn test_change_date_with_note_continuation() { + let data = vec![ + "1 CHAN", + "2 DATE 11 Jan 2001", + "2 NOTE This is a long note", + "3 CONT that continues on the next line", + ] + .join("\n"); + + let mut input = data.as_str(); + let change_date = ChangeDate::parse(&mut input).unwrap(); + + assert_eq!(change_date.date, Some("11 Jan 2001".to_string())); + assert_eq!(change_date.notes.len(), 1); + assert!(change_date.notes[0] + .note + .as_ref() + .unwrap() + .contains("This is a long note")); + assert!(change_date.notes[0] + .note + .as_ref() + .unwrap() + .contains("that continues on the next line")); + } + + #[test] + fn test_change_date_empty() { + let data = vec!["1 CHAN"].join("\n"); + + let mut input = data.as_str(); + let change_date = ChangeDate::parse(&mut input).unwrap(); + + assert_eq!(change_date.date, None); + assert_eq!(change_date.time, None); + assert_eq!(change_date.notes.len(), 0); + } + + #[test] + fn test_change_date_with_fractional_seconds() { + let data = vec!["1 CHAN", "2 DATE 1 JAN 1998", "3 TIME 13:57:24.80"].join("\n"); + + let mut input = data.as_str(); + let change_date = ChangeDate::parse(&mut input).unwrap(); + + assert_eq!(change_date.date, Some("1 JAN 1998".to_string())); + assert_eq!(change_date.time, Some("13:57:24.80".to_string())); + } + + #[test] + fn test_change_date_at_different_levels() { + // Test parsing at level 2 (common for nested structures) + let data = vec!["2 CHAN", "3 DATE 12 MAR 2000", "4 TIME 10:36:02"].join("\n"); + + let mut input = data.as_str(); + let change_date = ChangeDate::parse(&mut input).unwrap(); + + assert_eq!(change_date.date, Some("12 MAR 2000".to_string())); + assert_eq!(change_date.time, Some("10:36:02".to_string())); + } + + #[test] + fn test_change_date_stops_at_next_record() { + let data = vec!["1 CHAN", "2 DATE 13 JUN 2000", "3 TIME 17:07:32", "1 RIN 3"].join("\n"); + + let mut input = data.as_str(); + let change_date = ChangeDate::parse(&mut input).unwrap(); + + assert_eq!(change_date.date, Some("13 JUN 2000".to_string())); + assert_eq!(change_date.time, Some("17:07:32".to_string())); + + // Verify that RIN line is still available to parse + let next_line = Line::peek(&mut input).unwrap(); + assert_eq!(next_line.tag, "RIN"); + } + + #[test] + fn test_change_date_with_note_reference() { + let data = vec!["1 CHAN", "2 DATE 7 SEP 2000", "2 NOTE @N1@"].join("\n"); + + let mut input = data.as_str(); + let change_date = ChangeDate::parse(&mut input).unwrap(); + + assert_eq!(change_date.date, Some("7 SEP 2000".to_string())); + assert_eq!(change_date.notes.len(), 1); + assert_eq!(change_date.notes[0].note, Some("@N1@".to_string())); + } +} diff --git a/src/types/family.rs b/src/types/family.rs index 986ddef..77c8bf0 100644 --- a/src/types/family.rs +++ b/src/types/family.rs @@ -3,8 +3,8 @@ use std::str::FromStr; use crate::{ parse, types::{ - AdoptedBy, FamilyEventDetail, Line, Note, Object, Pedigree, SourceCitation, UserReference, - Xref, + AdoptedBy, ChangeDate, FamilyEventDetail, Line, Note, Object, Pedigree, SourceCitation, + UserReference, Xref, }, }; use winnow::prelude::*; @@ -180,8 +180,8 @@ pub struct Family { /// Automated record ID pub automated_record_id: Option, - /// Change date - stores the DATE value from CHAN/DATE - pub change_date: Option, + /// Change date - full CHANGE_DATE structure with DATE, TIME, and NOTE + pub change_date: Option, // Legacy fields used for FAMC/FAMS child-to-family links // These are kept for backward compatibility with Individual parsing @@ -351,19 +351,11 @@ impl Family { consume = false; } "CHAN" => { - // Basic parsing - extract DATE value and skip rest of structure - let chan_level = line.level; - let _ = Line::parse(record); - - // Look for DATE tag at next level - while let Ok(peek) = Line::peek(record) { - if peek.level <= chan_level { - break; + if let Ok(change_date) = ChangeDate::parse(record) { + // Only set if we actually got data + if change_date.date.is_some() || !change_date.notes.is_empty() { + family.change_date = Some(change_date); } - if peek.tag == "DATE" && peek.level == chan_level + 1 { - family.change_date = Some(peek.value.to_string()); - } - let _ = Line::parse(record); } consume = false; } @@ -600,10 +592,40 @@ mod tests { let family = Family::parse(&mut record); assert_eq!(family.xref, Xref::new("@F2@".to_string())); - assert_eq!(family.change_date, Some("13 JUN 2000".to_string())); + assert!(family.change_date.is_some()); + let cd = family.change_date.as_ref().unwrap(); + assert_eq!(cd.date, Some("13 JUN 2000".to_string())); + assert_eq!(cd.time, Some("17:00:35".to_string())); + assert_eq!(cd.notes.len(), 0); assert_eq!(family.automated_record_id, Some("2".to_string())); } + #[test] + fn parse_fam_record_with_change_date_and_note() { + let data = vec![ + "0 @F2@ FAM", + "1 HUSB @I5@", + "1 CHAN", + "2 DATE 13 JUN 2000", + "3 TIME 17:00:35", + "2 NOTE Marriage certificate updated", + ] + .join("\n"); + let mut record = data.as_str(); + + let family = Family::parse(&mut record); + assert_eq!(family.xref, Xref::new("@F2@".to_string())); + assert!(family.change_date.is_some()); + let cd = family.change_date.as_ref().unwrap(); + assert_eq!(cd.date, Some("13 JUN 2000".to_string())); + assert_eq!(cd.time, Some("17:00:35".to_string())); + assert_eq!(cd.notes.len(), 1); + assert_eq!( + cd.notes[0].note, + Some("Marriage certificate updated".to_string()) + ); + } + #[test] fn parse_fam_record_with_source_citation() { let data = vec![ diff --git a/src/types/individual/individual.rs b/src/types/individual/individual.rs index 9e02999..0f6b25f 100644 --- a/src/types/individual/individual.rs +++ b/src/types/individual/individual.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use crate::parse; use crate::types::individual::name::*; -use crate::types::{Family, Line, Note, Object, SourceCitation, UserReference, Xref}; +use crate::types::{ChangeDate, Family, Line, Note, Object, SourceCitation, UserReference, Xref}; use winnow::prelude::*; use super::{Adoption, Birth, Christening, Death, IndividualEventDetail, Residence}; @@ -258,8 +258,8 @@ pub struct Individual { /// Automated record ID pub automated_record_id: Option, - /// Change date - stores the DATE value from CHAN/DATE - pub change_date: Option, + /// Change date - full CHANGE_DATE structure with DATE, TIME, and NOTE + pub change_date: Option, /// Submitter references pub submitters: Vec, @@ -713,24 +713,11 @@ impl Individual { individual.automated_record_id = Some(line.value.to_string()); } "CHAN" => { - // Parse CHAN structure to get DATE value - let level = line.level; - let _ = Line::parse(record); // consume CHAN line - - while !record.is_empty() { - let Ok(inner_line) = Line::peek(record) else { - break; - }; - - if inner_line.level <= level { - break; - } - - if inner_line.level == level + 1 && inner_line.tag == "DATE" { - individual.change_date = Some(inner_line.value.to_string()); + if let Ok(change_date) = ChangeDate::parse(record) { + // Only set if we actually got data + if change_date.date.is_some() || !change_date.notes.is_empty() { + individual.change_date = Some(change_date); } - - let _ = Line::parse(record); } parse = false; } @@ -2162,7 +2149,37 @@ mod tests { let mut record = buffer.as_str(); let indi = Individual::parse(&mut record); - assert_eq!(indi.change_date.as_ref().unwrap(), "15 JAN 2024"); + assert!(indi.change_date.is_some()); + let cd = indi.change_date.as_ref().unwrap(); + assert_eq!(cd.date, Some("15 JAN 2024".to_string())); + assert_eq!(cd.time, Some("14:30:00".to_string())); + assert_eq!(cd.notes.len(), 0); + } + + #[test] + fn test_individual_change_date_with_note() { + let data = vec![ + "0 @I1@ INDI", + "1 NAME Test /Person/", + "1 CHAN", + "2 DATE 15 JAN 2024", + "3 TIME 14:30:00", + "2 NOTE Record updated to fix name typo", + ]; + + let buffer = data.join("\n"); + let mut record = buffer.as_str(); + let indi = Individual::parse(&mut record); + + assert!(indi.change_date.is_some()); + let cd = indi.change_date.as_ref().unwrap(); + assert_eq!(cd.date, Some("15 JAN 2024".to_string())); + assert_eq!(cd.time, Some("14:30:00".to_string())); + assert_eq!(cd.notes.len(), 1); + assert_eq!( + cd.notes[0].note, + Some("Record updated to fix name typo".to_string()) + ); } #[test] diff --git a/src/types/mod.rs b/src/types/mod.rs index bd7b8a1..1262fd0 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -2,6 +2,7 @@ // top-level record types mod address; mod adopted_by; +mod change_date; mod character_set; mod corporation; mod datetime; @@ -31,6 +32,7 @@ mod xref; pub use address::*; pub use adopted_by::AdoptedBy; +pub use change_date::ChangeDate; pub use character_set::CharacterSet; pub use datetime::DateTime; pub use event::{EventDetail, EventTypeCitedFrom, FamilyEventDetail}; diff --git a/src/types/multimedia_record.rs b/src/types/multimedia_record.rs index dd1d87d..6ae665b 100644 --- a/src/types/multimedia_record.rs +++ b/src/types/multimedia_record.rs @@ -1,4 +1,4 @@ -use super::{Line, Note, SourceCitation, UserReference, Xref}; +use super::{ChangeDate, Line, Note, SourceCitation, UserReference, Xref}; use crate::parse; use winnow::prelude::*; @@ -40,8 +40,8 @@ pub struct MultimediaRecord { /// Source citations for this multimedia object pub source_citations: Vec, - /// Change date - stores the DATE value from CHAN/DATE (skips TIME and other structure) - pub change_date: Option, + /// Change date - full CHANGE_DATE structure with DATE, TIME, and NOTE + pub change_date: Option, } /// Represents a multimedia file within a MULTIMEDIA_RECORD @@ -140,19 +140,11 @@ impl MultimediaRecord { multimedia.automated_record_id = Some(line.value.to_string()); } "CHAN" => { - // Basic parsing - extract DATE value and skip rest of structure - let chan_level = line.level; - let _ = Line::parse(input); - - // Look for DATE tag at next level - while let Ok(peek) = Line::peek(input) { - if peek.level <= chan_level { - break; + if let Ok(change_date) = ChangeDate::parse(input) { + // Only set if we actually got data + if change_date.date.is_some() || !change_date.notes.is_empty() { + multimedia.change_date = Some(change_date); } - if peek.tag == "DATE" && peek.level == chan_level + 1 { - multimedia.change_date = Some(peek.value.to_string()); - } - let _ = Line::parse(input); } consume = false; } @@ -361,7 +353,11 @@ mod tests { let multimedia = MultimediaRecord::parse(&mut input).unwrap(); assert_eq!(multimedia.files.len(), 1); - assert_eq!(multimedia.change_date, Some("14 JAN 2001".to_string())); + assert!(multimedia.change_date.is_some()); + let cd = multimedia.change_date.as_ref().unwrap(); + assert_eq!(cd.date, Some("14 JAN 2001".to_string())); + assert_eq!(cd.time, Some("14:10:31".to_string())); + assert_eq!(cd.notes.len(), 0); } #[test] diff --git a/src/types/note_record.rs b/src/types/note_record.rs index 6abaf7a..2877d03 100644 --- a/src/types/note_record.rs +++ b/src/types/note_record.rs @@ -1,4 +1,4 @@ -use super::{Line, SourceCitation, UserReference, Xref}; +use super::{ChangeDate, Line, SourceCitation, UserReference, Xref}; use winnow::prelude::*; // NOTE_RECORD:= @@ -32,8 +32,8 @@ pub struct NoteRecord { /// Automated record ID pub automated_record_id: Option, - /// Change date (basic parsing, skips structure for now) - pub change_date: Option, + /// Change date - full CHANGE_DATE structure with DATE, TIME, and NOTE + pub change_date: Option, } impl NoteRecord { @@ -104,17 +104,11 @@ impl NoteRecord { note_record.automated_record_id = Some(line.value.to_string()); } "CHAN" => { - // For now, just skip CHAN and its children - // TODO: Implement full CHANGE_DATE parsing - let chan_level = line.level; - let _ = Line::parse(input); - - // Skip all child tags - while let Ok(peek) = Line::peek(input) { - if peek.level <= chan_level { - break; + if let Ok(change_date) = ChangeDate::parse(input) { + // Only set if we actually got data + if change_date.date.is_some() || !change_date.notes.is_empty() { + note_record.change_date = Some(change_date); } - let _ = Line::parse(input); } consume = false; } @@ -235,7 +229,11 @@ mod tests { let note = NoteRecord::parse(&mut input).unwrap(); assert_eq!(note.note, "Test note"); - // CHAN is parsed but not fully stored yet + assert!(note.change_date.is_some()); + let cd = note.change_date.as_ref().unwrap(); + assert_eq!(cd.date, Some("24 MAY 1999".to_string())); + assert_eq!(cd.time, Some("16:39:55".to_string())); + assert_eq!(cd.notes.len(), 0); } #[test] diff --git a/src/types/repository_record.rs b/src/types/repository_record.rs index 95c15ad..caf1b97 100644 --- a/src/types/repository_record.rs +++ b/src/types/repository_record.rs @@ -1,4 +1,4 @@ -use super::{Address, Line, Note, UserReference, Xref}; +use super::{Address, ChangeDate, Line, Note, UserReference, Xref}; use crate::parse; use winnow::prelude::*; @@ -37,8 +37,8 @@ pub struct RepositoryRecord { /// Automated record ID pub automated_record_id: Option, - /// Change date - stores the DATE value from CHAN/DATE (skips TIME and other structure) - pub change_date: Option, + /// Change date - full CHANGE_DATE structure with DATE, TIME, and NOTE + pub change_date: Option, } impl RepositoryRecord { @@ -117,19 +117,11 @@ impl RepositoryRecord { repository.automated_record_id = Some(line.value.to_string()); } "CHAN" => { - // Basic parsing - extract DATE value and skip rest of structure - let chan_level = line.level; - let _ = Line::parse(input); - - // Look for DATE tag at next level - while let Ok(peek) = Line::peek(input) { - if peek.level <= chan_level { - break; + if let Ok(change_date) = ChangeDate::parse(input) { + // Only set if we actually got data + if change_date.date.is_some() || !change_date.notes.is_empty() { + repository.change_date = Some(change_date); } - if peek.tag == "DATE" && peek.level == chan_level + 1 { - repository.change_date = Some(peek.value.to_string()); - } - let _ = Line::parse(input); } consume = false; } @@ -279,7 +271,11 @@ mod tests { let mut input = data.as_str(); let repository = RepositoryRecord::parse(&mut input).unwrap(); - assert_eq!(repository.change_date, Some("12 MAR 2000".to_string())); + assert!(repository.change_date.is_some()); + let cd = repository.change_date.as_ref().unwrap(); + assert_eq!(cd.date, Some("12 MAR 2000".to_string())); + assert_eq!(cd.time, Some("10:36:02".to_string())); + assert_eq!(cd.notes.len(), 0); } #[test] @@ -319,6 +315,9 @@ mod tests { assert_eq!(repository.notes.len(), 1); assert_eq!(repository.user_reference_numbers.len(), 1); assert_eq!(repository.automated_record_id, Some("1".to_string())); - assert_eq!(repository.change_date, Some("12 MAR 2000".to_string())); + assert!(repository.change_date.is_some()); + let cd = repository.change_date.as_ref().unwrap(); + assert_eq!(cd.date, Some("12 MAR 2000".to_string())); + assert_eq!(cd.time, Some("10:36:02".to_string())); } } diff --git a/src/types/source_record.rs b/src/types/source_record.rs index fe9bc3f..7cb90fb 100644 --- a/src/types/source_record.rs +++ b/src/types/source_record.rs @@ -1,4 +1,4 @@ -use super::{Line, Note, Object, Xref}; +use super::{ChangeDate, Line, Note, Object, Xref}; use crate::parse; use winnow::prelude::*; @@ -70,8 +70,8 @@ pub struct SourceRecord { /// Automated record ID pub automated_record_id: Option, - /// Change date (not yet fully parsed) - pub change_date: Option, + /// Change date - full CHANGE_DATE structure with DATE, TIME, and NOTE + pub change_date: Option, } /// Data about events recorded in a source @@ -218,17 +218,11 @@ impl SourceRecord { source.automated_record_id = Some(line.value.to_string()); } "CHAN" => { - // For now, just skip CHAN and its children - // TODO: Implement full CHANGE_DATE parsing - let chan_level = line.level; - let _ = Line::parse(input); - - // Skip all child tags - while let Ok(peek) = Line::peek(input) { - if peek.level <= chan_level { - break; + if let Ok(change_date) = ChangeDate::parse(input) { + // Only set if we actually got data + if change_date.date.is_some() || !change_date.notes.is_empty() { + source.change_date = Some(change_date); } - let _ = Line::parse(input); } consume = false; } diff --git a/src/types/submitter.rs b/src/types/submitter.rs index 7ae585d..a166dda 100644 --- a/src/types/submitter.rs +++ b/src/types/submitter.rs @@ -1,4 +1,4 @@ -use crate::types::{Address, Line, Note, Object, Xref}; +use crate::types::{Address, ChangeDate, Line, Note, Object, Xref}; // SUBMITTER_RECORD:= // n @@ SUBM {1:1} @@ -37,8 +37,8 @@ pub struct Submitter { /// Notes pub notes: Vec, - /// Change date - stores the DATE value from CHAN/DATE - pub change_date: Option, + /// Change date - full CHANGE_DATE structure with DATE, TIME, and NOTE + pub change_date: Option, } impl Submitter { @@ -115,24 +115,11 @@ impl Submitter { consume = false; } "CHAN" => { - // Parse CHAN structure to get DATE value - let level = line.level; - let _ = Line::parse(record); - - while !record.is_empty() { - let Ok(inner_line) = Line::peek(record) else { - break; - }; - - if inner_line.level <= level { - break; + if let Ok(change_date) = ChangeDate::parse(record) { + // Only set if we actually got data + if change_date.date.is_some() || !change_date.notes.is_empty() { + submitter.change_date = Some(change_date); } - - if inner_line.level == level + 1 && inner_line.tag == "DATE" { - submitter.change_date = Some(inner_line.value.to_string()); - } - - let _ = Line::parse(record); } consume = false; } @@ -228,7 +215,11 @@ mod tests { assert_eq!(submitter.registered_rfn.as_ref().unwrap(), "123456789"); assert_eq!(submitter.automated_record_id.as_ref().unwrap(), "1"); - assert_eq!(submitter.change_date.as_ref().unwrap(), "7 SEP 2000"); + assert!(submitter.change_date.is_some()); + let cd = submitter.change_date.as_ref().unwrap(); + assert_eq!(cd.date, Some("7 SEP 2000".to_string())); + assert_eq!(cd.time, Some("8:35:36".to_string())); + assert_eq!(cd.notes.len(), 0); assert_eq!(submitter.notes.len(), 1); let note = submitter.notes[0].note.as_ref().unwrap();