Skip to main content

ocpi_tariffs/explain/
render.rs

1//! Render an [`Explanation`] into Markdown prose in a chosen [`Language`].
2//!
3//! [`build`](super::tariff::build) has already made every semantic decision, so this module only
4//! chooses words, number formatting and word order. The Markdown skeleton (bold labels, bulleted
5//! tier lists, paragraph spacing) is shared across languages because the supported languages are
6//! all Germanic and phrase these structures alike; only the vocabulary carried by the [`Language`]
7//! methods differs. To add a language, add a variant and fill in each method's match arm.
8
9use chrono::{DateTime, NaiveDate, NaiveTime, TimeDelta, Utc};
10use rust_decimal::Decimal;
11
12use crate::{
13    currency, money::VatOrigin, tariff::v2x::DimensionType, Ampere, Kw, Kwh, Money, Price, Weekday,
14};
15
16use super::ir::{
17    Body, Bounds, Condition, ConditionPart, Dimension, Explanation, Fallback, Flat, FlatFee, Rate,
18    Scope, Section, TimeWindow, Validity,
19};
20
21/// A language an explanation can be rendered in. All supported languages are Germanic.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Language {
24    /// English (`en-US`).
25    EnUS,
26
27    /// Dutch (`nl-NL`).
28    NlNl,
29}
30
31/// Render a built explanation as Markdown in the given language.
32pub(super) fn render(explanation: &Explanation, language: Language) -> String {
33    let currency = explanation.currency;
34
35    match &explanation.body {
36        Body::Fallback(reason) => language.fallback(reason),
37        Body::Sections(sections) => sections
38            .iter()
39            .map(|section| render_section(section, currency, language))
40            .collect::<Vec<_>>()
41            .join("\n\n"),
42    }
43}
44
45/// Render one top-level section as its own paragraph (or bulleted block).
46fn render_section(section: &Section, currency: currency::Code, language: Language) -> String {
47    match section {
48        Section::Dimension(dimension) => render_dimension(dimension, currency, language),
49        Section::Flat(flat) => render_flat(flat, currency, language),
50        Section::Bounds(bounds) => language.bounds(bounds, currency),
51        Section::Validity(validity) => language.validity(validity),
52    }
53}
54
55/// Render a metered dimension: a single tier reads as one line, several tiers as a bulleted list,
56/// followed by any shared billing-step note and unreachable-tier note.
57fn render_dimension(dimension: &Dimension, currency: currency::Code, language: Language) -> String {
58    let subject = language.dimension_subject(dimension.kind);
59
60    // Each tier becomes (condition, rate body). A per-tier billing-step note is folded onto the
61    // body; it is only ever present when the dimension has several tiers, so it never shows on the
62    // single-line form.
63    let entries: Vec<(String, String)> = dimension
64        .tiers
65        .iter()
66        .map(|tier| {
67            let condition = render_condition(&tier.condition, dimension.kind, language);
68            let body = language.rate_body(&tier.rate, dimension.kind, currency);
69            let step = tier
70                .step
71                .map(|step| language.inline_step_note(step, dimension.kind))
72                .unwrap_or_default();
73            (condition, format!("{body}{step}"))
74        })
75        .collect();
76
77    let section = render_tier_list(subject, &entries);
78
79    let section = match dimension.uniform_step {
80        Some(step) => format!(
81            "{section}\n\n_{}_",
82            language.dimension_step_note(step, dimension.kind)
83        ),
84        None => section,
85    };
86
87    if dimension.dropped_unreachable {
88        format!("{section}\n\n_{}_", language.dropped_tiers_note())
89    } else {
90        section
91    }
92}
93
94/// Render the flat fee: a single tier reads as one line, several as a bulleted list.
95fn render_flat(flat: &Flat, currency: currency::Code, language: Language) -> String {
96    let label = language.flat_fee_label();
97
98    let entries: Vec<(String, String)> = flat
99        .tiers
100        .iter()
101        .map(|tier| {
102            let condition = render_condition(&tier.condition, DimensionType::Flat, language);
103            let fee = language.flat_fee_body(&tier.fee, currency);
104            (condition, fee)
105        })
106        .collect();
107
108    render_tier_list(label, &entries)
109}
110
111/// Render a labelled list of `(condition, body)` tiers as a Markdown section.
112///
113/// A single unconditional tier reads as `**Label:** body.`; a single conditional tier as
114/// `**Label:** condition, body.`; and several tiers as a bulleted list, one `- Condition: body` per
115/// tier. Shared by the metered dimensions and the flat fee so they lay out identically.
116fn render_tier_list(label: &str, entries: &[(String, String)]) -> String {
117    match entries {
118        [(condition, body)] if condition.is_empty() => format!("**{label}:** {body}."),
119        [(condition, body)] => format!("**{label}:** {condition}, {body}."),
120        _ => {
121            let bullets: Vec<String> = entries
122                .iter()
123                .map(|(condition, body)| format!("- {}: {body}", capitalize_first(condition)))
124                .collect();
125            format!("**{label}:**\n\n{}", bullets.join("\n"))
126        }
127    }
128}
129
130/// Render a tier's condition. `kind` is only used by the "remaining ..." catch-all, which names the
131/// dimension's own quantity.
132fn render_condition(condition: &Condition, kind: DimensionType, language: Language) -> String {
133    match condition {
134        Condition::Always => String::new(),
135        Condition::Otherwise => language.otherwise().to_owned(),
136        Condition::Remaining => language.remaining(kind),
137        Condition::When(parts) => parts
138            .iter()
139            .map(|part| language.condition_part(part))
140            .collect::<Vec<_>>()
141            .join(", "),
142    }
143}
144
145/// Capitalize the first character of a string, leaving the rest untouched.
146fn capitalize_first(text: &str) -> String {
147    let mut chars = text.chars();
148    match chars.next() {
149        Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
150        None => String::new(),
151    }
152}
153
154impl Language {
155    /// The sentence subject / label for a metered dimension, e.g. "Charging time".
156    fn dimension_subject(self, kind: DimensionType) -> &'static str {
157        match kind {
158            DimensionType::Energy => match self {
159                Language::EnUS => "Energy",
160                Language::NlNl => "Energietarief",
161            },
162            DimensionType::Time => match self {
163                Language::EnUS => "Charging time",
164                Language::NlNl => "Tarief tijdens het laden",
165            },
166            DimensionType::ParkingTime => match self {
167                Language::EnUS => "Idle time (connected but not charging)",
168                Language::NlNl => "Parkeertijd (aangesloten maar niet aan het laden)",
169            },
170            // The flat fee is not a metered dimension; it has its own label.
171            DimensionType::Flat => "",
172        }
173    }
174
175    /// The per-unit rate phrase for a metered dimension, e.g. "per hour".
176    fn dimension_unit(self, kind: DimensionType) -> &'static str {
177        match kind {
178            DimensionType::Energy => "per kWh",
179            DimensionType::Time | DimensionType::ParkingTime => match self {
180                Language::EnUS => "per hour",
181                Language::NlNl => "per uur",
182            },
183            DimensionType::Flat => "",
184        }
185    }
186
187    /// The catch-all phrase naming the dimension's own remaining quantity, e.g. "for the remaining
188    /// charging time".
189    fn remaining(self, kind: DimensionType) -> String {
190        let phrase = match kind {
191            DimensionType::Energy => match self {
192                Language::EnUS => "for the remaining energy",
193                Language::NlNl => "de resterende energie",
194            },
195            DimensionType::Time => match self {
196                Language::EnUS => "for the remaining charging time",
197                Language::NlNl => "de resterende laadtijd",
198            },
199            DimensionType::ParkingTime => match self {
200                Language::EnUS => "for the remaining idle time",
201                Language::NlNl => "de resterende parkeertijd",
202            },
203            DimensionType::Flat => "",
204        };
205        phrase.to_owned()
206    }
207
208    /// The catch-all word used when an earlier tier was gated by a non-consumption qualifier.
209    fn otherwise(self) -> &'static str {
210        match self {
211            Language::EnUS => "otherwise",
212            Language::NlNl => "anders",
213        }
214    }
215
216    /// The label for the flat-fee section.
217    fn flat_fee_label(self) -> &'static str {
218        match self {
219            Language::EnUS => "Flat fee",
220            Language::NlNl => "Vast tarief",
221        }
222    }
223
224    /// The rate body of a metered tier: "free" or an amount with its per-unit phrase and VAT.
225    fn rate_body(self, rate: &Rate, kind: DimensionType, currency: currency::Code) -> String {
226        match rate {
227            Rate::Free => match self {
228                Language::EnUS => "free".to_owned(),
229                Language::NlNl => "gratis".to_owned(),
230            },
231            Rate::Priced { amount, vat } => format!(
232                "{} {}{}",
233                self.money(*amount, currency),
234                self.dimension_unit(kind),
235                self.vat_clause(*vat)
236            ),
237        }
238    }
239
240    /// The fee body of a flat tier: "no fee" or an amount charged per session, with VAT.
241    fn flat_fee_body(self, fee: &FlatFee, currency: currency::Code) -> String {
242        match fee {
243            FlatFee::NoFee => match self {
244                Language::EnUS => "no fee".to_owned(),
245                Language::NlNl => "geen kosten".to_owned(),
246            },
247            FlatFee::Charged { amount, vat } => {
248                let per_session = match self {
249                    Language::EnUS => "per session",
250                    Language::NlNl => "per sessie",
251                };
252                format!(
253                    "{} {per_session}{}",
254                    self.money(*amount, currency),
255                    self.vat_clause(*vat)
256                )
257            }
258        }
259    }
260
261    /// Render one clause of a tier's condition as a lowercase phrase.
262    fn condition_part(self, part: &ConditionPart) -> String {
263        match part {
264            ConditionPart::TimeWindow(window) => self.time_window(window),
265            ConditionPart::Weekdays(days) => {
266                let names: Vec<&str> = days.iter().copied().map(|day| self.weekday(day)).collect();
267                let on = match self {
268                    Language::EnUS => "on",
269                    Language::NlNl => "op",
270                };
271                format!("{on} {}", names.join(", "))
272            }
273            ConditionPart::DateRange { start, end } => self.date_range(*start, *end),
274            ConditionPart::MinPower(power) => match self {
275                Language::EnUS => format!("while charging at {} or more", self.kw(*power)),
276                Language::NlNl => format!("bij laden op {} of meer", self.kw(*power)),
277            },
278            ConditionPart::MaxPower(power) => match self {
279                Language::EnUS => format!("while charging below {}", self.kw(*power)),
280                Language::NlNl => format!("bij laden onder {}", self.kw(*power)),
281            },
282            ConditionPart::MinCurrent(current) => match self {
283                Language::EnUS => format!("at {} or more", self.ampere(*current)),
284                Language::NlNl => format!("bij {} of meer", self.ampere(*current)),
285            },
286            ConditionPart::MaxCurrent(current) => match self {
287                Language::EnUS => format!("below {}", self.ampere(*current)),
288                Language::NlNl => format!("onder {}", self.ampere(*current)),
289            },
290            ConditionPart::DurationScope(scope) => self.duration_scope(scope),
291            ConditionPart::EnergyScope(scope) => self.energy_scope(scope),
292        }
293    }
294
295    /// Render a time-of-day window clause.
296    fn time_window(self, window: &TimeWindow) -> String {
297        match (self, window) {
298            (Language::EnUS, TimeWindow::Empty { start, end }) => {
299                format!("never (its window {} to {} is empty)", hm(*start), hm(*end))
300            }
301            (Language::EnUS, TimeWindow::Wrapping { start, end }) => {
302                format!("between {} and {} the next day", hm(*start), hm(*end))
303            }
304            (Language::EnUS, TimeWindow::Between { start, end }) => {
305                format!("between {} and {}", hm(*start), hm(*end))
306            }
307            (Language::EnUS, TimeWindow::From { start }) => {
308                format!("from {} onwards", hm(*start))
309            }
310            (Language::EnUS, TimeWindow::Before { end }) => format!("before {}", hm(*end)),
311            (Language::NlNl, TimeWindow::Empty { start, end }) => {
312                format!(
313                    "nooit (het venster {} tot {} is leeg)",
314                    hm(*start),
315                    hm(*end)
316                )
317            }
318            (Language::NlNl, TimeWindow::Wrapping { start, end }) => {
319                format!("tussen {} en {} de volgende dag", hm(*start), hm(*end))
320            }
321            (Language::NlNl, TimeWindow::Between { start, end }) => {
322                format!("tussen {} en {}", hm(*start), hm(*end))
323            }
324            (Language::NlNl, TimeWindow::From { start }) => format!("vanaf {}", hm(*start)),
325            (Language::NlNl, TimeWindow::Before { end }) => format!("voor {}", hm(*end)),
326        }
327    }
328
329    /// Render a calendar-date range clause. At least one of `start`/`end` is present.
330    fn date_range(self, start: Option<NaiveDate>, end: Option<NaiveDate>) -> String {
331        match (start, end) {
332            (Some(start), Some(end)) => match self {
333                Language::EnUS => format!("from {} until {}", date(start), date(end)),
334                Language::NlNl => format!("van {} tot {}", date(start), date(end)),
335            },
336            (Some(start), None) => match self {
337                Language::EnUS => format!("from {} onwards", date(start)),
338                Language::NlNl => format!("vanaf {}", date(start)),
339            },
340            (None, Some(end)) => match self {
341                Language::EnUS => format!("until {}", date(end)),
342                Language::NlNl => format!("tot {}", date(end)),
343            },
344            // Never built with both ends absent.
345            (None, None) => String::new(),
346        }
347    }
348
349    /// Render an elapsed-session-duration scope clause.
350    fn duration_scope(self, scope: &Scope<TimeDelta>) -> String {
351        match (self, scope) {
352            (Language::EnUS, Scope::UpTo(max)) => {
353                format!("for the first {}", self.duration(*max))
354            }
355            (Language::EnUS, Scope::After(min)) => {
356                format!("after the first {}", self.duration(*min))
357            }
358            (Language::EnUS, Scope::Between(min, max)) => format!(
359                "between {} and {} into the session",
360                self.duration(*min),
361                self.duration(*max)
362            ),
363            (Language::NlNl, Scope::UpTo(max)) => {
364                format!("het eerste {}", self.duration(*max))
365            }
366            (Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.duration(*min)),
367            (Language::NlNl, Scope::Between(min, max)) => format!(
368                "tussen {} en {} tijdens de sessie",
369                self.duration(*min),
370                self.duration(*max)
371            ),
372        }
373    }
374
375    /// Render a consumed-energy scope clause.
376    fn energy_scope(self, scope: &Scope<Kwh>) -> String {
377        match (self, scope) {
378            (Language::EnUS, Scope::UpTo(max)) => format!("for the first {}", self.kwh(*max)),
379            (Language::EnUS, Scope::After(min)) => format!("after the first {}", self.kwh(*min)),
380            (Language::EnUS, Scope::Between(min, max)) => {
381                format!("from {} to {}", self.kwh(*min), self.kwh(*max))
382            }
383            (Language::NlNl, Scope::UpTo(max)) => format!("het eerste {}", self.kwh(*max)),
384            (Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.kwh(*min)),
385            (Language::NlNl, Scope::Between(min, max)) => {
386                format!("van {} tot {}", self.kwh(*min), self.kwh(*max))
387            }
388        }
389    }
390
391    /// The overall `min_price`/`max_price` bounds, as one or two sentences.
392    fn bounds(self, bounds: &Bounds, currency: currency::Code) -> String {
393        let mut sentences = Vec::new();
394
395        if let Some(min) = bounds.min {
396            sentences.push(match self {
397                Language::EnUS => {
398                    format!(
399                        "A session always costs at least {}.",
400                        self.price(min, currency)
401                    )
402                }
403                Language::NlNl => {
404                    format!(
405                        "Een sessie kost altijd minstens {}.",
406                        self.price(min, currency)
407                    )
408                }
409            });
410        }
411        if let Some(max) = bounds.max {
412            sentences.push(match self {
413                Language::EnUS => {
414                    format!(
415                        "A session never costs more than {}.",
416                        self.price(max, currency)
417                    )
418                }
419                Language::NlNl => {
420                    format!(
421                        "Een sessie kost nooit meer dan {}.",
422                        self.price(max, currency)
423                    )
424                }
425            });
426        }
427
428        sentences.join(" ")
429    }
430
431    /// The validity window of the tariff itself.
432    fn validity(self, validity: &Validity) -> String {
433        match (self, validity) {
434            (Language::EnUS, Validity::Between { start, end }) => format!(
435                "This tariff is only valid from {} until {} (UTC).",
436                datetime(*start),
437                datetime(*end)
438            ),
439            (Language::EnUS, Validity::From { start }) => format!(
440                "This tariff only becomes active on {} (UTC).",
441                datetime(*start)
442            ),
443            (Language::EnUS, Validity::Until { end }) => format!(
444                "This tariff is no longer valid from {} (UTC).",
445                datetime(*end)
446            ),
447            (Language::NlNl, Validity::Between { start, end }) => format!(
448                "Dit tarief is alleen geldig van {} tot {} (UTC).",
449                datetime(*start),
450                datetime(*end)
451            ),
452            (Language::NlNl, Validity::From { start }) => {
453                format!("Dit tarief wordt pas actief op {} (UTC).", datetime(*start))
454            }
455            (Language::NlNl, Validity::Until { end }) => {
456                format!(
457                    "Dit tarief is niet langer geldig vanaf {} (UTC).",
458                    datetime(*end)
459                )
460            }
461        }
462    }
463
464    /// The reason a tariff produces no charging narrative.
465    fn fallback(self, reason: &Fallback) -> String {
466        match (self, reason) {
467            (Language::EnUS, Fallback::ReservationOnly) => {
468                "This tariff never charges a regular charging session: every element applies only \
469                 to reservation sessions."
470                    .to_owned()
471            }
472            (Language::EnUS, Fallback::NoPriceComponents) => {
473                "This tariff charges nothing: none of its applicable elements define a price \
474                 component."
475                    .to_owned()
476            }
477            (Language::EnUS, Fallback::FreeFlatOnly) => {
478                "This tariff is free: its only charge is a flat fee of zero.".to_owned()
479            }
480            (Language::NlNl, Fallback::ReservationOnly) => {
481                "Dit tarief brengt nooit kosten in rekening voor een gewone laadsessie: elk element \
482                 geldt alleen voor reserveringssessies."
483                    .to_owned()
484            }
485            (Language::NlNl, Fallback::NoPriceComponents) => {
486                "Dit tarief brengt niets in rekening: geen van de toepasselijke elementen \
487                 definieert een prijscomponent."
488                    .to_owned()
489            }
490            (Language::NlNl, Fallback::FreeFlatOnly) => {
491                "Dit tarief is gratis: er zijn geen kosten.".to_owned()
492            }
493        }
494    }
495
496    /// The note describing a billing step shared by every tier of a dimension (without the
497    /// surrounding italics).
498    fn dimension_step_note(self, step_size: u64, kind: DimensionType) -> String {
499        let magnitude = self.step_magnitude(step_size, kind);
500        match self {
501            Language::EnUS => format!("Billed in steps of {magnitude}, rounded up."),
502            Language::NlNl => format!("Berekend in stappen van {magnitude}, naar boven afgerond."),
503        }
504    }
505
506    /// The inline note describing a single tier's own billing step, as a trailing clause.
507    fn inline_step_note(self, step_size: u64, kind: DimensionType) -> String {
508        let magnitude = self.step_magnitude(step_size, kind);
509        match self {
510            Language::EnUS => format!(" (billed in steps of {magnitude}, rounded up)"),
511            Language::NlNl => {
512                format!(" (berekend in stappen van {magnitude}, naar boven afgerond)")
513            }
514        }
515    }
516
517    /// The note that unreachable tiers were dropped (without the surrounding italics).
518    fn dropped_tiers_note(self) -> &'static str {
519        match self {
520            Language::EnUS => {
521                "Any later tiers never apply, because an earlier rate already matches every session."
522            }
523            Language::NlNl => {
524                "Latere niveaus gelden nooit, omdat een eerder tarief al op elke sessie van toepassing is."
525            }
526        }
527    }
528
529    /// The magnitude of a billing step in the unit appropriate to the dimension, e.g. "0.1 kWh" or
530    /// "1 minute".
531    ///
532    /// The energy `step_size` is given in Wh by the spec, but it is shown in kWh because readers
533    /// expect energy in kWh and tend to read "Wh" as a typo. Time steps are humanized (60 -> "1
534    /// minute", 900 -> "15 minutes") rather than shown as a raw second count.
535    fn step_magnitude(self, step_size: u64, kind: DimensionType) -> String {
536        match kind {
537            DimensionType::Energy => {
538                let kwh = Kwh::from_watt_hours(Decimal::from(step_size));
539                format!("{} kWh", self.decimal(Decimal::from(kwh).normalize()))
540            }
541            DimensionType::Time | DimensionType::ParkingTime => {
542                self.duration_seconds(i64::try_from(step_size).unwrap_or(i64::MAX))
543            }
544            DimensionType::Flat => String::new(),
545        }
546    }
547
548    /// Format a `Money` amount with its currency symbol.
549    ///
550    /// Two decimals reads best for ordinary prices, but a small nonzero rate must never collapse to
551    /// a "0.00" string and read as free; in that case the value's own precision is used instead.
552    fn money(self, money: Money, currency: currency::Code) -> String {
553        let amount = Decimal::from(money);
554        let symbol = currency.into_symbol();
555
556        let digits = if amount != Decimal::ZERO && amount.round_dp(2) == Decimal::ZERO {
557            self.decimal(amount.normalize())
558        } else {
559            self.decimal_fixed(amount)
560        };
561
562        match self {
563            Language::EnUS => format!("{symbol}{digits}"),
564            Language::NlNl => format!("{symbol} {digits}"),
565        }
566    }
567
568    /// Format a `Price` (which may carry a VAT-inclusive value) with its currency symbol.
569    fn price(self, price: Price, currency: currency::Code) -> String {
570        let incl_vat = match self {
571            Language::EnUS => "incl. VAT",
572            Language::NlNl => "incl. btw",
573        };
574        match price.incl_vat {
575            Some(incl) => format!(
576                "{} ({} {incl_vat})",
577                self.money(price.excl_vat, currency),
578                self.money(incl, currency)
579            ),
580            None => self.money(price.excl_vat, currency),
581        }
582    }
583
584    /// A trailing clause describing the VAT applied to a rate, or an empty string when none applies.
585    fn vat_clause(self, vat: VatOrigin) -> String {
586        match vat {
587            // `v2.1.1` tariffs carry no VAT information, so saying nothing is the honest choice.
588            VatOrigin::Unknown | VatOrigin::NotProvided => String::new(),
589            VatOrigin::Provided(vat) => {
590                let percent = self.decimal(Decimal::from(vat).normalize());
591                match self {
592                    Language::EnUS => format!(" (excl. {percent} VAT)"),
593                    Language::NlNl => format!(" (excl. {percent} btw)"),
594                }
595            }
596        }
597    }
598
599    /// Format a `Kwh` value without trailing zeros, e.g. "20 kWh".
600    fn kwh(self, value: Kwh) -> String {
601        format!("{} kWh", self.decimal(Decimal::from(value).normalize()))
602    }
603
604    /// Format a `Kw` value without trailing zeros, e.g. "11 kW".
605    fn kw(self, value: Kw) -> String {
606        format!("{} kW", self.decimal(Decimal::from(value).normalize()))
607    }
608
609    /// Format an `Ampere` value without trailing zeros, e.g. "16 A".
610    fn ampere(self, value: Ampere) -> String {
611        format!("{} A", self.decimal(Decimal::from(value).normalize()))
612    }
613
614    /// Render a duration as a friendly phrase such as "3 hours" or "1 hour 30 minutes".
615    fn duration(self, duration: TimeDelta) -> String {
616        self.duration_seconds(duration.num_seconds().max(0))
617    }
618
619    /// Render a whole number of seconds as a friendly phrase such as "3 hours" or "1 minute".
620    fn duration_seconds(self, total_seconds: i64) -> String {
621        let total_seconds = total_seconds.max(0);
622        let hours = total_seconds / 3600;
623        let minutes = (total_seconds % 3600) / 60;
624        let seconds = total_seconds % 60;
625
626        let mut parts = Vec::new();
627
628        if hours > 0 {
629            parts.push(self.time_unit(hours, TimeUnit::Hour));
630        }
631        if minutes > 0 {
632            parts.push(self.time_unit(minutes, TimeUnit::Minute));
633        }
634        if seconds > 0 {
635            parts.push(self.time_unit(seconds, TimeUnit::Second));
636        }
637
638        if parts.is_empty() {
639            self.time_unit(0, TimeUnit::Second)
640        } else {
641            parts.join(" ")
642        }
643    }
644
645    /// Format a count with a singular/plural time unit, e.g. "1 hour" / "3 hours".
646    fn time_unit(self, count: i64, unit: TimeUnit) -> String {
647        let noun = match (self, unit) {
648            (Language::EnUS, TimeUnit::Hour) if count == 1 => "hour",
649            (Language::EnUS, TimeUnit::Hour) => "hours",
650            (Language::EnUS, TimeUnit::Minute) if count == 1 => "minute",
651            (Language::EnUS, TimeUnit::Minute) => "minutes",
652            (Language::EnUS, TimeUnit::Second) if count == 1 => "second",
653            (Language::EnUS, TimeUnit::Second) => "seconds",
654            // Dutch "uur" does not inflect for number: "1 uur", "2 uur".
655            (Language::NlNl, TimeUnit::Hour) => "uur",
656            (Language::NlNl, TimeUnit::Minute) if count == 1 => "minuut",
657            (Language::NlNl, TimeUnit::Minute) => "minuten",
658            (Language::NlNl, TimeUnit::Second) if count == 1 => "seconde",
659            (Language::NlNl, TimeUnit::Second) => "seconden",
660        };
661        format!("{count} {noun}")
662    }
663
664    /// The English name of a weekday; Dutch names are lowercase, as Dutch does not capitalize them.
665    fn weekday(self, day: Weekday) -> &'static str {
666        match (self, day) {
667            (Language::EnUS, Weekday::Monday) => "Monday",
668            (Language::EnUS, Weekday::Tuesday) => "Tuesday",
669            (Language::EnUS, Weekday::Wednesday) => "Wednesday",
670            (Language::EnUS, Weekday::Thursday) => "Thursday",
671            (Language::EnUS, Weekday::Friday) => "Friday",
672            (Language::EnUS, Weekday::Saturday) => "Saturday",
673            (Language::EnUS, Weekday::Sunday) => "Sunday",
674            (Language::NlNl, Weekday::Monday) => "maandag",
675            (Language::NlNl, Weekday::Tuesday) => "dinsdag",
676            (Language::NlNl, Weekday::Wednesday) => "woensdag",
677            (Language::NlNl, Weekday::Thursday) => "donderdag",
678            (Language::NlNl, Weekday::Friday) => "vrijdag",
679            (Language::NlNl, Weekday::Saturday) => "zaterdag",
680            (Language::NlNl, Weekday::Sunday) => "zondag",
681        }
682    }
683
684    /// Render a normalized decimal, using the language's decimal separator.
685    fn decimal(self, value: Decimal) -> String {
686        let text = value.to_string();
687        match self {
688            Language::EnUS => text,
689            Language::NlNl => text.replace('.', ","),
690        }
691    }
692
693    /// Render a decimal fixed to two places, using the language's decimal separator.
694    fn decimal_fixed(self, value: Decimal) -> String {
695        let text = format!("{value:.2}");
696        match self {
697            Language::EnUS => text,
698            Language::NlNl => text.replace('.', ","),
699        }
700    }
701}
702
703/// A unit of elapsed time, used to select the right singular/plural noun.
704#[derive(Clone, Copy)]
705enum TimeUnit {
706    Hour,
707    Minute,
708    Second,
709}
710
711/// Format a `NaiveTime` as `HH:MM`. Shared across languages.
712fn hm(time: NaiveTime) -> String {
713    time.format("%H:%M").to_string()
714}
715
716/// Format a `NaiveDate` as an ISO `YYYY-MM-DD` date. Shared across languages for unambiguity.
717fn date(value: NaiveDate) -> String {
718    value.to_string()
719}
720
721/// Format a UTC instant as `YYYY-MM-DD HH:MM`. Shared across languages.
722fn datetime(value: DateTime<Utc>) -> String {
723    value.format("%Y-%m-%d %H:%M").to_string()
724}