1use crate::error::{Code, Result, TimeError};
28use crate::tier::{Tier, TierName, NAMED};
29
30#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
32#[non_exhaustive]
33pub enum LocaleId {
34 #[default]
36 En,
37 Ru,
39}
40
41impl LocaleId {
42 pub const fn tag(self) -> &'static str {
44 match self {
45 LocaleId::En => "en",
46 LocaleId::Ru => "ru",
47 }
48 }
49
50 pub const ALL: &'static [LocaleId] = &[LocaleId::En, LocaleId::Ru];
52
53 pub fn parse(tag: &str) -> Result<LocaleId> {
55 LocaleId::ALL
56 .iter()
57 .copied()
58 .find(|l| l.tag() == tag)
59 .ok_or(TimeError::with_context(
60 Code::E0010,
61 "unknown locale; shipped locales are en and ru",
62 ))
63 }
64}
65
66#[derive(Clone, Copy, PartialEq, Eq, Debug)]
68pub struct Names {
69 pub singular: &'static str,
71 pub plural: &'static str,
73}
74
75const fn n(singular: &'static str, plural: &'static str) -> Names {
76 Names { singular, plural }
77}
78
79const EN: &[(TierName, Names)] = &[
82 (TierName::Deep, n("deep", "deeps")),
83 (TierName::Drift, n("drift", "drifts")),
84 (TierName::Span, n("span", "spans")),
85 (TierName::Sweep, n("sweep", "sweeps")),
86 (TierName::Arc, n("arc", "arcs")),
87 (TierName::Beat, n("beat", "beats")),
88 (TierName::Flicker, n("flicker", "flickers")),
89 (TierName::Glint, n("glint", "glints")),
90 (TierName::Spark, n("spark", "sparks")),
91 (TierName::Tick, n("tick", "ticks")),
92];
93
94const RU: &[(TierName, Names)] = &[
96 (TierName::Deep, n("глубь", "глуби")),
97 (TierName::Drift, n("дрейф", "дрейфы")),
98 (TierName::Span, n("срок", "сроки")),
99 (TierName::Sweep, n("обход", "обходы")),
100 (TierName::Arc, n("дуга", "дуги")),
101 (TierName::Beat, n("бой", "бои")),
102 (TierName::Flicker, n("мерцание", "мерцания")),
103 (TierName::Glint, n("блик", "блики")),
104 (TierName::Spark, n("искра", "искры")),
105 (TierName::Tick, n("тик", "тики")),
106];
107
108pub const fn table(locale: LocaleId) -> &'static [(TierName, Names)] {
110 match locale {
111 LocaleId::En => EN,
112 LocaleId::Ru => RU,
113 }
114}
115
116pub fn names_of(locale: LocaleId, tier: Tier) -> Option<Names> {
120 let key = crate::tier::name_of(tier)?;
121 table(locale)
122 .iter()
123 .find(|(k, _)| *k == key)
124 .map(|(_, v)| *v)
125}
126
127#[cfg(feature = "alloc")]
132pub fn display(locale: LocaleId, tier: Tier) -> alloc::string::String {
133 use alloc::string::ToString;
134 match names_of(locale, tier) {
135 Some(n) => n.singular.to_string(),
136 None => tier.to_string(),
137 }
138}
139
140pub fn resolve(locale: LocaleId, s: &str) -> Result<Tier> {
145 let t = s.trim();
146
147 if let Some(k) = t.strip_prefix('T') {
149 if let Ok(idx) = k.parse::<i8>() {
150 return Tier::new(idx);
151 }
152 }
153 if let Some(e) = t.strip_prefix("5^") {
154 if let Ok(exp) = e.parse::<u32>() {
155 return Tier::from_exponent(exp);
156 }
157 }
158
159 let lowered = ascii_lower(t);
161 for (key, names) in table(locale) {
162 if eq_fold(names.singular, &lowered) || eq_fold(names.plural, &lowered) {
163 return tier_of_name(*key);
164 }
165 }
166 for (k, key) in NAMED {
167 if eq_fold(key.key(), &lowered) {
168 return Tier::new(*k);
169 }
170 }
171 Err(TimeError::with_context(
172 Code::E0011,
173 "unknown tier name; try a locale name, a stable key, T<k>, or 5^e",
174 ))
175}
176
177fn tier_of_name(key: TierName) -> Result<Tier> {
178 NAMED
179 .iter()
180 .find(|(_, k)| *k == key)
181 .map(|(idx, _)| Tier::new(*idx))
182 .unwrap_or(Err(TimeError::new(Code::E0011)))
183}
184
185fn eq_fold(candidate: &str, lowered_input: &str) -> bool {
191 if candidate.is_ascii() {
192 candidate.eq_ignore_ascii_case(lowered_input)
193 } else {
194 candidate == lowered_input
195 }
196}
197
198#[cfg(feature = "alloc")]
199fn ascii_lower(s: &str) -> alloc::string::String {
200 s.chars()
201 .map(|c| if c.is_ascii() { c.to_ascii_lowercase() } else { c })
202 .collect()
203}
204
205#[cfg(not(feature = "alloc"))]
206fn ascii_lower(s: &str) -> &str {
207 s
208}
209
210pub fn validate(locale: LocaleId) -> Result<()> {
216 let t = table(locale);
217 for (_, key) in NAMED {
219 let count = t.iter().filter(|(k, _)| k == key).count();
220 if count != 1 {
221 return Err(TimeError::with_context(
222 Code::E0010,
223 "locale table does not cover every named tier exactly once",
224 ));
225 }
226 }
227 for (i, (_, a)) in t.iter().enumerate() {
229 for (_, b) in t.iter().skip(i + 1) {
230 if a.singular == b.singular
231 || a.plural == b.plural
232 || a.singular == b.plural
233 || a.plural == b.singular
234 {
235 return Err(TimeError::with_context(
236 Code::E0011,
237 "duplicate name in the active locale table",
238 ));
239 }
240 }
241 }
242 Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn every_shipped_locale_is_valid() {
251 for l in LocaleId::ALL {
252 validate(*l).unwrap_or_else(|e| panic!("locale {} is invalid: {e}", l.tag()));
253 }
254 }
255
256 #[test]
257 fn every_locale_covers_every_named_tier() {
258 for l in LocaleId::ALL {
261 assert_eq!(table(*l).len(), NAMED.len(), "locale {}", l.tag());
262 for (k, _) in NAMED {
263 let tier = Tier::new(*k).unwrap();
264 assert!(
265 names_of(*l, tier).is_some(),
266 "locale {} is missing T{k}",
267 l.tag()
268 );
269 }
270 }
271 }
272
273 #[test]
274 fn unnamed_tiers_stay_unnamed_in_every_locale() {
275 for l in LocaleId::ALL {
277 for k in [6i8, 10, 32, -4, -8, -11] {
278 let tier = Tier::new(k).unwrap();
279 assert!(names_of(*l, tier).is_none(), "T{k} in {}", l.tag());
280 assert_eq!(display(*l, tier), alloc::format!("T{k}"));
282 }
283 }
284 }
285
286 #[test]
287 fn names_resolve_in_both_locales() {
288 assert_eq!(resolve(LocaleId::En, "deep").unwrap(), Tier::DEEP);
289 assert_eq!(resolve(LocaleId::En, "deeps").unwrap(), Tier::DEEP);
290 assert_eq!(resolve(LocaleId::Ru, "глубь").unwrap(), Tier::DEEP);
291 assert_eq!(resolve(LocaleId::Ru, "глуби").unwrap(), Tier::DEEP);
292 assert_eq!(resolve(LocaleId::Ru, "бой").unwrap(), Tier::BEAT);
293 assert_eq!(resolve(LocaleId::Ru, "тик").unwrap(), Tier::TICK);
294 }
295
296 #[test]
297 fn the_stable_key_works_in_any_locale() {
298 for l in LocaleId::ALL {
301 assert_eq!(resolve(*l, "beat").unwrap(), Tier::BEAT);
302 assert_eq!(resolve(*l, "deep").unwrap(), Tier::DEEP);
303 }
304 }
305
306 #[test]
307 fn index_and_exponent_notation_work_wherever_a_name_does() {
308 for l in LocaleId::ALL {
310 assert_eq!(resolve(*l, "T0").unwrap(), Tier::BEAT);
311 assert_eq!(resolve(*l, "T-12").unwrap(), Tier::TICK);
312 assert_eq!(resolve(*l, "5^60").unwrap(), Tier::BEAT);
313 assert_eq!(resolve(*l, "5^220").unwrap(), Tier::new(32).unwrap());
314 assert_eq!(resolve(*l, "T7").unwrap(), Tier::new(7).unwrap());
316 assert_eq!(resolve(*l, "5^95").unwrap(), Tier::new(7).unwrap());
317 }
318 }
319
320 #[test]
321 fn ascii_names_fold_case_but_cyrillic_is_taken_as_written() {
322 assert_eq!(resolve(LocaleId::En, "DEEP").unwrap(), Tier::DEEP);
323 assert_eq!(resolve(LocaleId::En, "Deeps").unwrap(), Tier::DEEP);
324 assert!(resolve(LocaleId::Ru, "глубь").is_ok());
327 assert!(resolve(LocaleId::Ru, "ГЛУБЬ").is_err());
328 }
329
330 #[test]
331 fn unknown_names_are_e0011() {
332 for l in LocaleId::ALL {
333 assert_eq!(resolve(*l, "aeon").unwrap_err().code, Code::E0011);
334 assert_eq!(resolve(*l, "").unwrap_err().code, Code::E0011);
335 }
336 assert_eq!(resolve(LocaleId::En, "5^61").unwrap_err().code, Code::E0080);
338 }
339
340 #[test]
341 fn locale_tags_round_trip() {
342 for l in LocaleId::ALL {
343 assert_eq!(LocaleId::parse(l.tag()).unwrap(), *l);
344 }
345 assert_eq!(LocaleId::parse("xx").unwrap_err().code, Code::E0010);
346 }
347
348 #[test]
349 fn no_calendar_units_appear_in_the_ladder() {
350 for l in LocaleId::ALL {
354 for (_, names) in table(*l) {
355 for n in [names.singular, names.plural] {
356 for forbidden in ["day", "year", "month", "week", "hour", "second"] {
357 assert_ne!(n, forbidden, "locale {} names a calendar unit", l.tag());
358 }
359 }
360 }
361 }
362 }
363
364 #[test]
365 fn names_avoid_the_content_appendix_d_rules_out() {
366 for l in LocaleId::ALL {
368 for (_, names) in table(*l) {
369 let s = names.singular;
370 for prefix in ["kilo", "mega", "giga", "tera", "milli", "micro", "nano"] {
371 assert!(!s.starts_with(prefix), "{s} carries a numeric prefix");
372 }
373 assert!(!s.is_empty());
374 assert!(!s.contains(' '), "{s} is not a single word");
376 }
377 }
378 }
379}