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}