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