use chrono::{DateTime, NaiveDate, NaiveTime, TimeDelta, Utc};
use rust_decimal::Decimal;
use crate::{
currency, money::VatOrigin, tariff::v2x::DimensionType, Ampere, Kw, Kwh, Money, Price, Weekday,
};
use super::ir::{
Body, Bounds, Condition, ConditionPart, Dimension, Explanation, Fallback, Flat, FlatFee, Rate,
Scope, Section, TimeWindow, Validity,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Language {
EnUS,
NlNl,
}
pub(super) fn render(explanation: &Explanation, language: Language) -> String {
let currency = explanation.currency;
match &explanation.body {
Body::Fallback(reason) => language.fallback(reason),
Body::Sections(sections) => sections
.iter()
.map(|section| render_section(section, currency, language))
.collect::<Vec<_>>()
.join("\n\n"),
}
}
fn render_section(section: &Section, currency: currency::Code, language: Language) -> String {
match section {
Section::Dimension(dimension) => render_dimension(dimension, currency, language),
Section::Flat(flat) => render_flat(flat, currency, language),
Section::Bounds(bounds) => language.bounds(bounds, currency),
Section::Validity(validity) => language.validity(validity),
}
}
fn render_dimension(dimension: &Dimension, currency: currency::Code, language: Language) -> String {
let subject = language.dimension_subject(dimension.kind);
let entries: Vec<(String, String)> = dimension
.tiers
.iter()
.map(|tier| {
let condition = render_condition(&tier.condition, dimension.kind, language);
let body = language.rate_body(&tier.rate, dimension.kind, currency);
let step = tier
.step
.map(|step| language.inline_step_note(step, dimension.kind))
.unwrap_or_default();
(condition, format!("{body}{step}"))
})
.collect();
let section = render_tier_list(subject, &entries);
let section = match dimension.uniform_step {
Some(step) => format!(
"{section}\n\n_{}_",
language.dimension_step_note(step, dimension.kind)
),
None => section,
};
if dimension.dropped_unreachable {
format!("{section}\n\n_{}_", language.dropped_tiers_note())
} else {
section
}
}
fn render_flat(flat: &Flat, currency: currency::Code, language: Language) -> String {
let label = language.flat_fee_label();
let entries: Vec<(String, String)> = flat
.tiers
.iter()
.map(|tier| {
let condition = render_condition(&tier.condition, DimensionType::Flat, language);
let fee = language.flat_fee_body(&tier.fee, currency);
(condition, fee)
})
.collect();
render_tier_list(label, &entries)
}
fn render_tier_list(label: &str, entries: &[(String, String)]) -> String {
match entries {
[(condition, body)] if condition.is_empty() => format!("**{label}:** {body}."),
[(condition, body)] => format!("**{label}:** {condition}, {body}."),
_ => {
let bullets: Vec<String> = entries
.iter()
.map(|(condition, body)| format!("- {}: {body}", capitalize_first(condition)))
.collect();
format!("**{label}:**\n\n{}", bullets.join("\n"))
}
}
}
fn render_condition(condition: &Condition, kind: DimensionType, language: Language) -> String {
match condition {
Condition::Always => String::new(),
Condition::Otherwise => language.otherwise().to_owned(),
Condition::Remaining => language.remaining(kind),
Condition::When(parts) => parts
.iter()
.map(|part| language.condition_part(part))
.collect::<Vec<_>>()
.join(", "),
}
}
fn capitalize_first(text: &str) -> String {
let mut chars = text.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
None => String::new(),
}
}
impl Language {
fn dimension_subject(self, kind: DimensionType) -> &'static str {
match kind {
DimensionType::Energy => match self {
Language::EnUS => "Energy",
Language::NlNl => "Energietarief",
},
DimensionType::Time => match self {
Language::EnUS => "Charging time",
Language::NlNl => "Tarief tijdens het laden",
},
DimensionType::ParkingTime => match self {
Language::EnUS => "Idle time (connected but not charging)",
Language::NlNl => "Parkeertijd (aangesloten maar niet aan het laden)",
},
DimensionType::Flat => "",
}
}
fn dimension_unit(self, kind: DimensionType) -> &'static str {
match kind {
DimensionType::Energy => "per kWh",
DimensionType::Time | DimensionType::ParkingTime => match self {
Language::EnUS => "per hour",
Language::NlNl => "per uur",
},
DimensionType::Flat => "",
}
}
fn remaining(self, kind: DimensionType) -> String {
let phrase = match kind {
DimensionType::Energy => match self {
Language::EnUS => "for the remaining energy",
Language::NlNl => "de resterende energie",
},
DimensionType::Time => match self {
Language::EnUS => "for the remaining charging time",
Language::NlNl => "de resterende laadtijd",
},
DimensionType::ParkingTime => match self {
Language::EnUS => "for the remaining idle time",
Language::NlNl => "de resterende parkeertijd",
},
DimensionType::Flat => "",
};
phrase.to_owned()
}
fn otherwise(self) -> &'static str {
match self {
Language::EnUS => "otherwise",
Language::NlNl => "anders",
}
}
fn flat_fee_label(self) -> &'static str {
match self {
Language::EnUS => "Flat fee",
Language::NlNl => "Vast tarief",
}
}
fn rate_body(self, rate: &Rate, kind: DimensionType, currency: currency::Code) -> String {
match rate {
Rate::Free => match self {
Language::EnUS => "free".to_owned(),
Language::NlNl => "gratis".to_owned(),
},
Rate::Priced { amount, vat } => format!(
"{} {}{}",
self.money(*amount, currency),
self.dimension_unit(kind),
self.vat_clause(*vat)
),
}
}
fn flat_fee_body(self, fee: &FlatFee, currency: currency::Code) -> String {
match fee {
FlatFee::NoFee => match self {
Language::EnUS => "no fee".to_owned(),
Language::NlNl => "geen kosten".to_owned(),
},
FlatFee::Charged { amount, vat } => {
let per_session = match self {
Language::EnUS => "per session",
Language::NlNl => "per sessie",
};
format!(
"{} {per_session}{}",
self.money(*amount, currency),
self.vat_clause(*vat)
)
}
}
}
fn condition_part(self, part: &ConditionPart) -> String {
match part {
ConditionPart::TimeWindow(window) => self.time_window(window),
ConditionPart::Weekdays(days) => {
let names: Vec<&str> = days.iter().copied().map(|day| self.weekday(day)).collect();
let on = match self {
Language::EnUS => "on",
Language::NlNl => "op",
};
format!("{on} {}", names.join(", "))
}
ConditionPart::DateRange { start, end } => self.date_range(*start, *end),
ConditionPart::MinPower(power) => match self {
Language::EnUS => format!("while charging at {} or more", self.kw(*power)),
Language::NlNl => format!("bij laden op {} of meer", self.kw(*power)),
},
ConditionPart::MaxPower(power) => match self {
Language::EnUS => format!("while charging below {}", self.kw(*power)),
Language::NlNl => format!("bij laden onder {}", self.kw(*power)),
},
ConditionPart::MinCurrent(current) => match self {
Language::EnUS => format!("at {} or more", self.ampere(*current)),
Language::NlNl => format!("bij {} of meer", self.ampere(*current)),
},
ConditionPart::MaxCurrent(current) => match self {
Language::EnUS => format!("below {}", self.ampere(*current)),
Language::NlNl => format!("onder {}", self.ampere(*current)),
},
ConditionPart::DurationScope(scope) => self.duration_scope(scope),
ConditionPart::EnergyScope(scope) => self.energy_scope(scope),
}
}
fn time_window(self, window: &TimeWindow) -> String {
match (self, window) {
(Language::EnUS, TimeWindow::Empty { start, end }) => {
format!("never (its window {} to {} is empty)", hm(*start), hm(*end))
}
(Language::EnUS, TimeWindow::Wrapping { start, end }) => {
format!("between {} and {} the next day", hm(*start), hm(*end))
}
(Language::EnUS, TimeWindow::Between { start, end }) => {
format!("between {} and {}", hm(*start), hm(*end))
}
(Language::EnUS, TimeWindow::From { start }) => {
format!("from {} onwards", hm(*start))
}
(Language::EnUS, TimeWindow::Before { end }) => format!("before {}", hm(*end)),
(Language::NlNl, TimeWindow::Empty { start, end }) => {
format!(
"nooit (het venster {} tot {} is leeg)",
hm(*start),
hm(*end)
)
}
(Language::NlNl, TimeWindow::Wrapping { start, end }) => {
format!("tussen {} en {} de volgende dag", hm(*start), hm(*end))
}
(Language::NlNl, TimeWindow::Between { start, end }) => {
format!("tussen {} en {}", hm(*start), hm(*end))
}
(Language::NlNl, TimeWindow::From { start }) => format!("vanaf {}", hm(*start)),
(Language::NlNl, TimeWindow::Before { end }) => format!("voor {}", hm(*end)),
}
}
fn date_range(self, start: Option<NaiveDate>, end: Option<NaiveDate>) -> String {
match (start, end) {
(Some(start), Some(end)) => match self {
Language::EnUS => format!("from {} until {}", date(start), date(end)),
Language::NlNl => format!("van {} tot {}", date(start), date(end)),
},
(Some(start), None) => match self {
Language::EnUS => format!("from {} onwards", date(start)),
Language::NlNl => format!("vanaf {}", date(start)),
},
(None, Some(end)) => match self {
Language::EnUS => format!("until {}", date(end)),
Language::NlNl => format!("tot {}", date(end)),
},
(None, None) => String::new(),
}
}
fn duration_scope(self, scope: &Scope<TimeDelta>) -> String {
match (self, scope) {
(Language::EnUS, Scope::UpTo(max)) => {
format!("for the first {}", self.duration(*max))
}
(Language::EnUS, Scope::After(min)) => {
format!("after the first {}", self.duration(*min))
}
(Language::EnUS, Scope::Between(min, max)) => format!(
"between {} and {} into the session",
self.duration(*min),
self.duration(*max)
),
(Language::NlNl, Scope::UpTo(max)) => {
format!("het eerste {}", self.duration(*max))
}
(Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.duration(*min)),
(Language::NlNl, Scope::Between(min, max)) => format!(
"tussen {} en {} tijdens de sessie",
self.duration(*min),
self.duration(*max)
),
}
}
fn energy_scope(self, scope: &Scope<Kwh>) -> String {
match (self, scope) {
(Language::EnUS, Scope::UpTo(max)) => format!("for the first {}", self.kwh(*max)),
(Language::EnUS, Scope::After(min)) => format!("after the first {}", self.kwh(*min)),
(Language::EnUS, Scope::Between(min, max)) => {
format!("from {} to {}", self.kwh(*min), self.kwh(*max))
}
(Language::NlNl, Scope::UpTo(max)) => format!("het eerste {}", self.kwh(*max)),
(Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.kwh(*min)),
(Language::NlNl, Scope::Between(min, max)) => {
format!("van {} tot {}", self.kwh(*min), self.kwh(*max))
}
}
}
fn bounds(self, bounds: &Bounds, currency: currency::Code) -> String {
let mut sentences = Vec::new();
if let Some(min) = bounds.min {
sentences.push(match self {
Language::EnUS => {
format!(
"A session always costs at least {}.",
self.price(min, currency)
)
}
Language::NlNl => {
format!(
"Een sessie kost altijd minstens {}.",
self.price(min, currency)
)
}
});
}
if let Some(max) = bounds.max {
sentences.push(match self {
Language::EnUS => {
format!(
"A session never costs more than {}.",
self.price(max, currency)
)
}
Language::NlNl => {
format!(
"Een sessie kost nooit meer dan {}.",
self.price(max, currency)
)
}
});
}
sentences.join(" ")
}
fn validity(self, validity: &Validity) -> String {
match (self, validity) {
(Language::EnUS, Validity::Between { start, end }) => format!(
"This tariff is only valid from {} until {} (UTC).",
datetime(*start),
datetime(*end)
),
(Language::EnUS, Validity::From { start }) => format!(
"This tariff only becomes active on {} (UTC).",
datetime(*start)
),
(Language::EnUS, Validity::Until { end }) => format!(
"This tariff is no longer valid from {} (UTC).",
datetime(*end)
),
(Language::NlNl, Validity::Between { start, end }) => format!(
"Dit tarief is alleen geldig van {} tot {} (UTC).",
datetime(*start),
datetime(*end)
),
(Language::NlNl, Validity::From { start }) => {
format!("Dit tarief wordt pas actief op {} (UTC).", datetime(*start))
}
(Language::NlNl, Validity::Until { end }) => {
format!(
"Dit tarief is niet langer geldig vanaf {} (UTC).",
datetime(*end)
)
}
}
}
fn fallback(self, reason: &Fallback) -> String {
match (self, reason) {
(Language::EnUS, Fallback::ReservationOnly) => {
"This tariff never charges a regular charging session: every element applies only \
to reservation sessions."
.to_owned()
}
(Language::EnUS, Fallback::NoPriceComponents) => {
"This tariff charges nothing: none of its applicable elements define a price \
component."
.to_owned()
}
(Language::EnUS, Fallback::FreeFlatOnly) => {
"This tariff is free: its only charge is a flat fee of zero.".to_owned()
}
(Language::NlNl, Fallback::ReservationOnly) => {
"Dit tarief brengt nooit kosten in rekening voor een gewone laadsessie: elk element \
geldt alleen voor reserveringssessies."
.to_owned()
}
(Language::NlNl, Fallback::NoPriceComponents) => {
"Dit tarief brengt niets in rekening: geen van de toepasselijke elementen \
definieert een prijscomponent."
.to_owned()
}
(Language::NlNl, Fallback::FreeFlatOnly) => {
"Dit tarief is gratis: er zijn geen kosten.".to_owned()
}
}
}
fn dimension_step_note(self, step_size: u64, kind: DimensionType) -> String {
let magnitude = self.step_magnitude(step_size, kind);
match self {
Language::EnUS => format!("Billed in steps of {magnitude}, rounded up."),
Language::NlNl => format!("Berekend in stappen van {magnitude}, naar boven afgerond."),
}
}
fn inline_step_note(self, step_size: u64, kind: DimensionType) -> String {
let magnitude = self.step_magnitude(step_size, kind);
match self {
Language::EnUS => format!(" (billed in steps of {magnitude}, rounded up)"),
Language::NlNl => {
format!(" (berekend in stappen van {magnitude}, naar boven afgerond)")
}
}
}
fn dropped_tiers_note(self) -> &'static str {
match self {
Language::EnUS => {
"Any later tiers never apply, because an earlier rate already matches every session."
}
Language::NlNl => {
"Latere niveaus gelden nooit, omdat een eerder tarief al op elke sessie van toepassing is."
}
}
}
fn step_magnitude(self, step_size: u64, kind: DimensionType) -> String {
match kind {
DimensionType::Energy => {
let kwh = Kwh::from_watt_hours(Decimal::from(step_size));
format!("{} kWh", self.decimal(Decimal::from(kwh).normalize()))
}
DimensionType::Time | DimensionType::ParkingTime => {
self.duration_seconds(i64::try_from(step_size).unwrap_or(i64::MAX))
}
DimensionType::Flat => String::new(),
}
}
fn money(self, money: Money, currency: currency::Code) -> String {
let amount = Decimal::from(money);
let symbol = currency.into_symbol();
let digits = if amount != Decimal::ZERO && amount.round_dp(2) == Decimal::ZERO {
self.decimal(amount.normalize())
} else {
self.decimal_fixed(amount)
};
match self {
Language::EnUS => format!("{symbol}{digits}"),
Language::NlNl => format!("{symbol} {digits}"),
}
}
fn price(self, price: Price, currency: currency::Code) -> String {
let incl_vat = match self {
Language::EnUS => "incl. VAT",
Language::NlNl => "incl. btw",
};
match price.incl_vat {
Some(incl) => format!(
"{} ({} {incl_vat})",
self.money(price.excl_vat, currency),
self.money(incl, currency)
),
None => self.money(price.excl_vat, currency),
}
}
fn vat_clause(self, vat: VatOrigin) -> String {
match vat {
VatOrigin::Unknown | VatOrigin::NotProvided => String::new(),
VatOrigin::Provided(vat) => {
let percent = self.decimal(Decimal::from(vat).normalize());
match self {
Language::EnUS => format!(" (excl. {percent} VAT)"),
Language::NlNl => format!(" (excl. {percent} btw)"),
}
}
}
}
fn kwh(self, value: Kwh) -> String {
format!("{} kWh", self.decimal(Decimal::from(value).normalize()))
}
fn kw(self, value: Kw) -> String {
format!("{} kW", self.decimal(Decimal::from(value).normalize()))
}
fn ampere(self, value: Ampere) -> String {
format!("{} A", self.decimal(Decimal::from(value).normalize()))
}
fn duration(self, duration: TimeDelta) -> String {
self.duration_seconds(duration.num_seconds().max(0))
}
fn duration_seconds(self, total_seconds: i64) -> String {
let total_seconds = total_seconds.max(0);
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
let mut parts = Vec::new();
if hours > 0 {
parts.push(self.time_unit(hours, TimeUnit::Hour));
}
if minutes > 0 {
parts.push(self.time_unit(minutes, TimeUnit::Minute));
}
if seconds > 0 {
parts.push(self.time_unit(seconds, TimeUnit::Second));
}
if parts.is_empty() {
self.time_unit(0, TimeUnit::Second)
} else {
parts.join(" ")
}
}
fn time_unit(self, count: i64, unit: TimeUnit) -> String {
let noun = match (self, unit) {
(Language::EnUS, TimeUnit::Hour) if count == 1 => "hour",
(Language::EnUS, TimeUnit::Hour) => "hours",
(Language::EnUS, TimeUnit::Minute) if count == 1 => "minute",
(Language::EnUS, TimeUnit::Minute) => "minutes",
(Language::EnUS, TimeUnit::Second) if count == 1 => "second",
(Language::EnUS, TimeUnit::Second) => "seconds",
(Language::NlNl, TimeUnit::Hour) => "uur",
(Language::NlNl, TimeUnit::Minute) if count == 1 => "minuut",
(Language::NlNl, TimeUnit::Minute) => "minuten",
(Language::NlNl, TimeUnit::Second) if count == 1 => "seconde",
(Language::NlNl, TimeUnit::Second) => "seconden",
};
format!("{count} {noun}")
}
fn weekday(self, day: Weekday) -> &'static str {
match (self, day) {
(Language::EnUS, Weekday::Monday) => "Monday",
(Language::EnUS, Weekday::Tuesday) => "Tuesday",
(Language::EnUS, Weekday::Wednesday) => "Wednesday",
(Language::EnUS, Weekday::Thursday) => "Thursday",
(Language::EnUS, Weekday::Friday) => "Friday",
(Language::EnUS, Weekday::Saturday) => "Saturday",
(Language::EnUS, Weekday::Sunday) => "Sunday",
(Language::NlNl, Weekday::Monday) => "maandag",
(Language::NlNl, Weekday::Tuesday) => "dinsdag",
(Language::NlNl, Weekday::Wednesday) => "woensdag",
(Language::NlNl, Weekday::Thursday) => "donderdag",
(Language::NlNl, Weekday::Friday) => "vrijdag",
(Language::NlNl, Weekday::Saturday) => "zaterdag",
(Language::NlNl, Weekday::Sunday) => "zondag",
}
}
fn decimal(self, value: Decimal) -> String {
let text = value.to_string();
match self {
Language::EnUS => text,
Language::NlNl => text.replace('.', ","),
}
}
fn decimal_fixed(self, value: Decimal) -> String {
let text = format!("{value:.2}");
match self {
Language::EnUS => text,
Language::NlNl => text.replace('.', ","),
}
}
}
#[derive(Clone, Copy)]
enum TimeUnit {
Hour,
Minute,
Second,
}
fn hm(time: NaiveTime) -> String {
time.format("%H:%M").to_string()
}
fn date(value: NaiveDate) -> String {
value.to_string()
}
fn datetime(value: DateTime<Utc>) -> String {
value.format("%Y-%m-%d %H:%M").to_string()
}