Skip to main content

edtf_normalize/
lib.rs

1//! Deterministic prose-date → EDTF normalizer (GitHub issue #20).
2//!
3//! Real people typing dates into forms need instant, offline, deterministic
4//! normalization — "1980s" → `198X` on every keystroke, the same answer every
5//! time. This crate is a bounded pattern grammar over date prose: values are
6//! constructed through the [`edtf_core`] model and rendered via its canonical
7//! `Display`, then re-parsed, so every output is valid, canonical EDTF by
8//! construction. Anything outside the grammar is [`Outcome::NoMatch`] — never
9//! a silent guess. Genuinely ambiguous inputs ("12/04/1985") report every
10//! reading instead of picking one.
11//!
12//! Pattern tables are per-language ([`Language::English`] and
13//! [`Language::Russian`] today); adding a locale means adding one table
14//! struct, not touching the grammar. Every judgement call is a numbered
15//! N-decision in `docs/normalize-notes.md`, cited from the [`Note`] values
16//! attached to results.
17//!
18//! ```
19//! use edtf_normalize::{normalize, Outcome};
20//!
21//! let Outcome::Normalized(n) = normalize("circa 1920") else { panic!() };
22//! assert_eq!(n.edtf, "1920~");
23//!
24//! // "12/04/1985" is DD/MM or MM/DD — the form gets both readings, not a guess.
25//! assert!(matches!(normalize("12/04/1985"), Outcome::Ambiguous(_)));
26//! ```
27
28#![no_std]
29
30extern crate alloc;
31
32use alloc::string::String;
33use alloc::vec::Vec;
34
35use edtf_core::Edtf;
36
37mod engine;
38mod tables;
39
40/// Which pattern-table set to match against.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum Language {
43    /// English prose ("circa 1920", "19th century", "the 1980s").
44    #[default]
45    English,
46    /// Russian prose ("около 1920 г.", "XIX век", "1980-е").
47    Russian,
48}
49
50/// Resolution order for all-numeric dates where both readings are plausible.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum NumericOrder {
53    /// `12/04/1985` reads as 12 April (DD/MM/YYYY).
54    DayFirst,
55    /// `12/04/1985` reads as December 4 (MM/DD/YYYY).
56    MonthFirst,
57}
58
59/// Caller-supplied context. Everything is optional; the defaults never guess.
60#[derive(Debug, Clone, Copy, Default)]
61pub struct Options {
62    /// Pattern-table language. Defaults to English.
63    pub language: Language,
64    /// Resolves the DD/MM vs MM/DD ambiguity (N5). Overrides any
65    /// locale-implied order. `None` (default) reports both readings.
66    pub numeric_order: Option<NumericOrder>,
67    /// Century base year for bare decades: `Some(1900)` reads "the 80s" as
68    /// `198X` (N6). `None` (default) reports the plausible readings.
69    /// Domain is `0..=9999` (the value's century is used); values above 9999
70    /// are ignored as if unset.
71    pub default_century: Option<u16>,
72}
73
74/// Why an output looks the way it does. Each variant cites the N-decision in
75/// `docs/normalize-notes.md` that governs it, so a form can explain the
76/// conversion to the person typing.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Note {
79    /// The input was already valid EDTF; only canonicalized.
80    AlreadyValidEdtf,
81    /// early/mid/late or half-of-century mapped to a decade-rounded interval (N1).
82    CenturyPartInterval,
83    /// An early/mid/late modifier was dropped as false precision and recorded (N1).
84    ModifierDropped,
85    /// "19th century" → `18XX`: centuries run 1801–1900 (N2).
86    CenturyMask,
87    /// BC centuries have no digit mask (negative years cannot carry `X`);
88    /// emitted as exact intervals like `-0199/-0100` (N2).
89    BcCenturyInterval,
90    /// BC year converted to astronomical numbering: year 0 exists, so
91    /// 500 BC → `-0499` (N3).
92    AstronomicalYear,
93    /// "1914-18": the end year inherits the start's century (N4).
94    ElidedEndYear,
95    /// Numeric date order was provable from the input: a year-first layout,
96    /// or a field whose value exceeds 12 (N5).
97    NumericUnambiguous,
98    /// Numeric date order resolved by [`Options::numeric_order`] (N5).
99    NumericResolvedByOption,
100    /// Numeric date order implied by the language's convention (N5).
101    NumericResolvedByLocale,
102    /// Day and month were equal, so the order cannot matter (N5).
103    NumericOrderIrrelevant,
104    /// Both numeric readings are plausible; each is reported (N5).
105    NumericOrderAmbiguous,
106    /// A decade form with more than one reading ("1900s", "the 80s") (N6).
107    DecadeAmbiguity,
108    /// Bare decade resolved by [`Options::default_century`] (N6).
109    DefaultCenturyApplied,
110    /// Season name mapped to an ISO 8601-2 sub-year grouping code (N7).
111    SeasonCode,
112    /// before/after rendered as an open interval `../X` or `X/..` (N8).
113    OpenInterval,
114    /// A date with no year: the year is masked as `XXXX` (N9).
115    MissingYearMasked,
116    /// A whole-expression qualifier was distributed over interval endpoints (N10).
117    QualifierDistributed,
118    /// "X or Y": each alternative reported instead of picking one (N14).
119    OrAlternatives,
120    /// `NNNN-NN` collides between an EDTF sub-year code and an elided year
121    /// range; both readings reported (N13).
122    SeasonRangeCollision,
123    /// Century read from a Roman numeral, Cyrillic lookalike letters
124    /// tolerated ("XIX век", "ХІХ век") (N15).
125    RomanCentury,
126    /// A bare decade tied to an explicit century ("60-е годы XIX века" →
127    /// `186X`) (N6).
128    DecadeOfCentury,
129    /// A range endpoint without a year inherited the other endpoint's stated
130    /// year ("June-July 1940" → `1940-06/1940-07`) (N16).
131    EndpointYearDistributed,
132    /// "winter 1941-42" may be one boundary-spanning winter or a
133    /// season-to-year range; both readings reported (N17).
134    CrossYearSeason,
135}
136
137impl Note {
138    /// The N-decision in `docs/normalize-notes.md` this note cites, if any.
139    pub fn decision(self) -> Option<&'static str> {
140        match self {
141            Note::AlreadyValidEdtf => None,
142            Note::CenturyPartInterval | Note::ModifierDropped => Some("N1"),
143            Note::CenturyMask | Note::BcCenturyInterval => Some("N2"),
144            Note::AstronomicalYear => Some("N3"),
145            Note::ElidedEndYear => Some("N4"),
146            Note::NumericUnambiguous
147            | Note::NumericResolvedByOption
148            | Note::NumericResolvedByLocale
149            | Note::NumericOrderIrrelevant
150            | Note::NumericOrderAmbiguous => Some("N5"),
151            Note::DecadeAmbiguity | Note::DefaultCenturyApplied | Note::DecadeOfCentury => {
152                Some("N6")
153            }
154            Note::SeasonCode => Some("N7"),
155            Note::OpenInterval => Some("N8"),
156            Note::MissingYearMasked => Some("N9"),
157            Note::QualifierDistributed => Some("N10"),
158            Note::SeasonRangeCollision => Some("N13"),
159            Note::OrAlternatives => Some("N14"),
160            Note::RomanCentury => Some("N15"),
161            Note::EndpointYearDistributed => Some("N16"),
162            Note::CrossYearSeason => Some("N17"),
163        }
164    }
165
166    /// A short human-readable explanation of the note.
167    pub fn message(self) -> &'static str {
168        match self {
169            Note::AlreadyValidEdtf => "input was already valid EDTF; canonicalized",
170            Note::CenturyPartInterval => {
171                "part-of-century phrase mapped to a decade-rounded year interval"
172            }
173            Note::ModifierDropped => {
174                "early/mid/late modifier dropped (sub-decade precision would be false)"
175            }
176            Note::CenturyMask => "Nth century runs (N-1)01 to N00, so it masks as (N-1)XX",
177            Note::BcCenturyInterval => {
178                "BC centuries cannot be digit-masked; emitted as an exact year interval"
179            }
180            Note::AstronomicalYear => "BC year converted to astronomical numbering (year 0 exists)",
181            Note::ElidedEndYear => "elided end year inherits the start year's century",
182            Note::NumericUnambiguous => {
183                "field order provable from the input (year-first layout or a value over 12)"
184            }
185            Note::NumericResolvedByOption => "field order resolved by caller options",
186            Note::NumericResolvedByLocale => "field order implied by the language's convention",
187            Note::NumericOrderIrrelevant => "day and month are equal; order cannot matter",
188            Note::NumericOrderAmbiguous => "day/month order unknowable from the input",
189            Note::DecadeAmbiguity => "decade form has more than one plausible reading",
190            Note::DefaultCenturyApplied => "bare decade resolved by the configured century",
191            Note::SeasonCode => "season mapped to ISO 8601-2 sub-year grouping code",
192            Note::OpenInterval => "before/after expressed as an open interval",
193            Note::MissingYearMasked => "no year given; year masked as XXXX",
194            Note::QualifierDistributed => {
195                "whole-expression qualifier applied to every interval endpoint"
196            }
197            Note::OrAlternatives => "alternatives reported instead of picking one",
198            Note::SeasonRangeCollision => {
199                "NNNN-NN is both an EDTF sub-year code and a plausible year range"
200            }
201            Note::RomanCentury => {
202                "century read from a Roman numeral (Cyrillic lookalike letters tolerated)"
203            }
204            Note::DecadeOfCentury => "decade tied to the explicitly named century",
205            Note::EndpointYearDistributed => {
206                "endpoint without a year inherited the other endpoint's stated year"
207            }
208            Note::CrossYearSeason => {
209                "season-year-pair prose may name one boundary-spanning season or a range"
210            }
211        }
212    }
213}
214
215/// A successful, unambiguous normalization.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct Normalized {
218    /// The canonical EDTF string. Guaranteed to parse in [`edtf_core`].
219    pub edtf: String,
220    /// The parsed value behind [`Normalized::edtf`] (always
221    /// `Edtf::parse(&edtf).unwrap()`).
222    pub value: Edtf,
223    /// Why the output looks the way it does.
224    pub notes: Vec<Note>,
225}
226
227/// One plausible reading of an ambiguous input.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct Interpretation {
230    /// The canonical EDTF string for this reading.
231    pub edtf: String,
232    /// The parsed value behind [`Interpretation::edtf`].
233    pub value: Edtf,
234    /// Which reading this is ("day-month-year", "century (19XX)", …).
235    pub reading: String,
236    /// Why this reading exists.
237    pub notes: Vec<Note>,
238}
239
240/// A set of plausible readings. Always at least two; the form should ask,
241/// not pick.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct Ambiguous {
244    /// Every plausible reading, in table order.
245    pub interpretations: Vec<Interpretation>,
246}
247
248/// Why a [`Outcome::NoMatch`] happened — the discriminator a bulk-import
249/// triage step routes on (N11/N12).
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum NoMatchReason {
252    /// The input is outside the pattern grammar (N11). Escalate to a human.
253    OutOfGrammar,
254    /// The input explicitly says there is no date ("unknown", "без даты")
255    /// (N12). What that means — `XXXX`, an empty column, an error — is form
256    /// policy.
257    ExplicitNoDate,
258    /// The prose matched, but every reading named an impossible or
259    /// contradictory date (February 30, a reversed range, an impossible
260    /// "or" alternative). A prose error: escalate, never guess (N14).
261    ImpossibleDate,
262}
263
264impl NoMatchReason {
265    /// The N-decision in `docs/normalize-notes.md` this reason cites, if any.
266    pub fn decision(self) -> Option<&'static str> {
267        match self {
268            NoMatchReason::OutOfGrammar => Some("N11"),
269            NoMatchReason::ExplicitNoDate => Some("N12"),
270            NoMatchReason::ImpossibleDate => Some("N14"),
271        }
272    }
273}
274
275/// The honest return type: a match, several readings, or nothing — never a
276/// silent guess.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum Outcome {
279    /// One deterministic answer.
280    Normalized(Normalized),
281    /// More than one plausible reading; the caller decides.
282    Ambiguous(Ambiguous),
283    /// No answer, with the reason a triage step needs — this crate will not
284    /// guess.
285    NoMatch {
286        /// Why nothing was produced.
287        reason: NoMatchReason,
288    },
289}
290
291/// Normalize English date prose with default options.
292pub fn normalize(input: &str) -> Outcome {
293    engine::run(input, &Options::default())
294}
295
296/// Normalize with explicit [`Options`] (language, numeric order, default
297/// century).
298pub fn normalize_with(input: &str, options: &Options) -> Outcome {
299    engine::run(input, options)
300}