cletter 0.1.0

Locale-correct business correspondence: composes cgreet, cfarewell and cink with locale tables for opening, subject, resolution and orthography
Documentation
//! Locale-correct business correspondence, composed from focused units.
//!
//! `cletter` is the front door: it re-exports [`cgreet`] (salutations),
//! [`cfarewell`] (valedictions) and [`cink`] (signature images), and adds
//! what only makes sense together — locale resolution, openings, subjects,
//! orthography, and advisories.
//!
//! The locale tables live as data in `tables/*.json` (see
//! `tables/README.md`); `tests/vectors/*.json` is the executable contract
//! every language port runs. The Typst module in `typst/` derives from the
//! same tables.
//!
//! One locale per document (BCP 47). Unknown locales fall back through the
//! base language to English. Explicit overrides always win — tables supply
//! defaults, never commands. Same input always yields the same output: no
//! models, no I/O.

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)
}

/// Normalize a loose language string to a canonical BCP 47 code from the
/// supported set: trimmed, `ll-RR` case-fixed, base fallback for unknown
/// regions. Returns `None` for anything unrecognized.
#[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
}

/// Extract an uppercase ISO country code from a free-text location:
/// country keywords first, then Swiss cantons (`City, Canton` shape).
/// Matching lowercases Unicode-aware (locations are free text, unlike the
/// ASCII-only table tokens), mirroring the reference implementation.
#[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
}

/// Resolve a language plus an optional free-text location to a BCP 47
/// locale: variant mapping through the country, base fallback, `en` when
/// nothing is recognized. No location preserves the language as given.
#[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)
}

/// Table key for a locale: exact code, base language, fallback.
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()
    }
}

/// `cgreet` region for locales whose salutation it implements.
#[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,
    }
}

/// Opening line: the `named` template with `{name}` filled when a name is
/// given, otherwise the formal address. An explicit override always wins.
#[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(),
    }
}

/// Subject line: prefix plus title, or the unsolicited-subject default when
/// no title is known. The override replaces the prefix only, mirroring how
/// per-workspace subject choices compose.
#[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(),
    }
}

/// Locale-correct salutation: German locales delegate to `cgreet` (honorific
/// and title parsing, comma rule, generic fallback); all others resolve the
/// opening template.
#[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),
    )
}

/// Swiss orthography: ß→ss where the locale demands it (`de-CH` only).
/// Anything else passes through untouched.
#[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()
    }
}

/// Non-blocking advisories for a recipient name: the missing-name warning
/// in every locale, the honorific warning for German records. Empty means
/// the record is clean.
#[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(&region.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");
    }
}