pub use cfarewell::{available_locales, closing};
pub use cgreet::{
Region, de_honorific_warning, de_salutation, recipient_salutation_warning,
salutation_honorific, salutation_last_name, salutation_surname, salutation_titles,
};
pub use cink::{
DecodedImage, ImageMime, default_max_pixels, exceeds_limits, image_snippet, normalize,
scale_to_fit, signature_size, supported_formats,
};
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
#[derive(serde::Deserialize)]
struct LocaleEntry {
formal: String,
named: String,
subject_prefix: String,
subject_unsolicited: String,
use_ss: bool,
#[serde(default)]
region: Option<String>,
}
#[derive(serde::Deserialize)]
struct LocalesFile {
locales: HashMap<String, LocaleEntry>,
supported: HashSet<String>,
fallback: String,
variants: HashMap<String, HashMap<String, String>>,
}
#[derive(serde::Deserialize)]
struct CountriesFile {
keywords: HashMap<String, String>,
cantons: HashSet<String>,
}
static LOCALES: LazyLock<LocalesFile> = LazyLock::new(|| {
serde_json::from_str(include_str!("../tables/locales.json"))
.expect("tables/locales.json is valid")
});
static COUNTRIES: LazyLock<CountriesFile> = LazyLock::new(|| {
serde_json::from_str(include_str!("../tables/countries.json"))
.expect("tables/countries.json is valid")
});
fn base_language(code: &str) -> &str {
code.split('-').next().unwrap_or(code)
}
#[must_use]
pub fn normalize_language(input: &str) -> Option<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
if LOCALES.supported.contains(trimmed) {
return Some(trimmed.to_owned());
}
if trimmed.contains('-') || trimmed.contains('_') {
let mut parts = trimmed.split(['-', '_']);
let base = parts.next().unwrap_or("").to_ascii_lowercase();
if let Some(region) = parts.next() {
let code = format!("{base}-{region}", region = region.to_ascii_uppercase());
if LOCALES.supported.contains(&code) {
return Some(code);
}
}
if LOCALES.supported.contains(&base) {
return Some(base);
}
return None;
}
let lower = trimmed.to_ascii_lowercase();
if LOCALES.supported.contains(&lower) {
return Some(lower);
}
None
}
#[must_use]
pub fn country_from_location(location: &str) -> Option<&'static str> {
let lower = location.to_lowercase();
let trimmed = lower.trim();
if trimmed.is_empty() {
return None;
}
for (keyword, code) in &COUNTRIES.keywords {
if trimmed.contains(keyword.as_str()) {
return Some(code.as_str());
}
}
for part in trimmed.split([',', ';']).map(str::trim) {
if COUNTRIES.cantons.contains(part) {
return Some("CH");
}
}
None
}
#[must_use]
pub fn resolve_locale(language: Option<&str>, location: Option<&str>) -> String {
let normalized = language
.and_then(normalize_language)
.unwrap_or_else(|| "en".to_owned());
let base = base_language(&normalized).to_owned();
let Some(country) = location.and_then(country_from_location) else {
return normalized;
};
LOCALES
.variants
.get(&base)
.and_then(|variants| variants.get(country))
.cloned()
.unwrap_or(base)
}
fn resolve_key(locale: &str) -> &str {
if LOCALES.locales.contains_key(locale) {
locale
} else if LOCALES.locales.contains_key(base_language(locale)) {
base_language(locale)
} else {
LOCALES.fallback.as_str()
}
}
#[must_use]
pub fn region_for(locale: &str) -> Option<Region> {
match LOCALES
.locales
.get(resolve_key(locale))
.and_then(|entry| entry.region.as_deref())
{
Some("de") => Some(Region::De),
Some("ch") => Some(Region::Ch),
Some("at") => Some(Region::At),
_ => None,
}
}
#[must_use]
pub fn opening(locale: &str, name: Option<&str>, override_opening: Option<&str>) -> String {
if let Some(custom) = override_opening {
return custom.to_owned();
}
let entry = &LOCALES.locales[resolve_key(locale)];
match name.map(str::trim).filter(|name| !name.is_empty()) {
Some(name) => entry.named.replace("{name}", name),
None => entry.formal.clone(),
}
}
#[must_use]
pub fn subject(locale: &str, title: Option<&str>, prefix_override: Option<&str>) -> String {
let entry = &LOCALES.locales[resolve_key(locale)];
match title.map(str::trim).filter(|title| !title.is_empty()) {
Some(title) => format!(
"{} {title}",
prefix_override.unwrap_or(&entry.subject_prefix)
),
None => entry.subject_unsolicited.clone(),
}
}
#[must_use]
pub fn salutation(locale: &str, name: &str) -> String {
region_for(locale).map_or_else(
|| opening(locale, Some(name), None),
|region| de_salutation(name, region),
)
}
#[must_use]
pub fn apply_ortho(locale: &str, text: &str) -> String {
if LOCALES.locales[resolve_key(locale)].use_ss {
text.replace('ß', "ss")
} else {
text.to_owned()
}
}
#[must_use]
pub fn warnings(location: &str, locale: &str, name: &str) -> Vec<String> {
let mut out = Vec::new();
if let Some(warning) = recipient_salutation_warning(location, name) {
out.push(warning);
}
if region_for(locale).is_some()
&& let Some(warning) = de_honorific_warning(location, name)
{
out.push(warning);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tables_schema() {
assert!(!LOCALES.locales.is_empty(), "table must not be empty");
assert!(
LOCALES.locales.contains_key(LOCALES.fallback.as_str()),
"fallback must be a known locale"
);
for (key, entry) in &LOCALES.locales {
assert!(
!entry.formal.is_empty(),
"formal must not be empty: {key:?}"
);
assert!(
entry.named.contains("{name}"),
"named template needs {{name}}: {key:?}"
);
assert!(
!entry.subject_prefix.is_empty(),
"subject prefix must not be empty: {key:?}"
);
assert!(
!entry.subject_unsolicited.is_empty(),
"unsolicited subject must not be empty: {key:?}"
);
if let Some(region) = &entry.region {
assert!(
["de", "ch", "at", "li"].contains(®ion.as_str()),
"unknown region: {key:?}"
);
}
}
for code in &LOCALES.supported {
assert!(!code.is_empty(), "supported code must not be empty");
}
for keyword in COUNTRIES.keywords.keys() {
assert_eq!(
keyword,
&keyword.to_ascii_lowercase(),
"keyword must be lowercase: {keyword:?}"
);
}
}
fn run_vector(file: &std::path::Path, vector: &serde_json::Value) {
let name = vector["name"].as_str().unwrap_or("<unnamed>");
let context = format!("{} :: {name}", file.display());
let actual: serde_json::Value = match vector["fn"].as_str().unwrap_or("") {
"resolve_locale" => {
let language = vector.get("language").and_then(serde_json::Value::as_str);
let location = vector.get("location").and_then(serde_json::Value::as_str);
serde_json::Value::String(resolve_locale(language, location))
}
"opening" => {
let locale = vector["locale"].as_str().expect("vector needs locale");
let who = vector.get("person").and_then(serde_json::Value::as_str);
let override_opening = vector.get("override").and_then(serde_json::Value::as_str);
serde_json::Value::String(opening(locale, who, override_opening))
}
"subject" => {
let locale = vector["locale"].as_str().expect("vector needs locale");
let title = vector.get("title").and_then(serde_json::Value::as_str);
let prefix = vector
.get("prefix_override")
.and_then(serde_json::Value::as_str);
serde_json::Value::String(subject(locale, title, prefix))
}
"salutation" => {
let locale = vector["locale"].as_str().expect("vector needs locale");
let who = vector
.get("person")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
serde_json::Value::String(salutation(locale, who))
}
"apply_ortho" => {
let locale = vector["locale"].as_str().expect("vector needs locale");
let text = vector["text"].as_str().unwrap_or("");
serde_json::Value::String(apply_ortho(locale, text))
}
"warnings" => {
let location = vector["location"].as_str().expect("vector needs location");
let locale = vector["locale"].as_str().expect("vector needs locale");
let who = vector
.get("person")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
serde_json::Value::Array(
warnings(location, locale, who)
.iter()
.map(|warning| serde_json::Value::String(warning.clone()))
.collect(),
)
}
other => panic!("{context}: unknown fn {other:?}"),
};
let expected = vector
.get("expected")
.cloned()
.unwrap_or(serde_json::Value::Null);
assert_eq!(actual, expected, "{context}");
}
#[test]
fn conformance_vectors() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors");
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(&dir)
.expect("tests/vectors exists")
.map(|entry| entry.expect("readable entry").path())
.collect();
files.sort();
assert!(!files.is_empty(), "no vector files in tests/vectors");
let mut count = 0;
for file in &files {
let raw = std::fs::read_to_string(file).expect("vector file is readable");
let vectors: Vec<serde_json::Value> =
serde_json::from_str(&raw).expect("vector file is valid JSON");
for vector in &vectors {
run_vector(file, vector);
count += 1;
}
}
assert!(count > 0, "no vectors ran");
}
}