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}