A lightweight, high-performance localization library for Rust.
Loads TOML language files, supports runtime locale switching, configurable paths per project, and automatic fallback chains. No proc macros, no codegen, no CLI tooling — just a macro and a map.
The crate is deliberately small. The goal is not to invent a translation platform. The goal is to make it easy to ship a Rust application with readable locale files, predictable fallback behavior, and almost no integration cost.
[dependencies]
lang-lib = "1.0.0"use lang_lib::{t, Lang};
// Point to your project's lang folder
Lang::set_path("assets/lang");
// Load the locales you need
Lang::load("en").unwrap();
Lang::load("es").unwrap();
// Set the active locale
Lang::set_locale("en");
// Set a fallback chain (checked when a key is missing)
Lang::set_fallbacks(vec!["en".to_string()]);
// Translate
let msg = t!("bad_password"); // active locale
let msg = t!("bad_password", "es"); // specific locale
let msg = t!("unknown_key", fallback: "Oops"); // inline fallback
let msg = t!("unknown_key", "es", fallback: "Oops"); // locale + fallbackIf you are wiring this into a real application, the usual setup looks like this.
your-app/
|- Cargo.toml
|- src/
| \- main.rs
\- locales/
|- en.toml
\- es.toml
Keep each file flat. One translation key maps to one string value.
# locales/en.toml
app_title = "Acme Control Panel"
login_title = "Sign in"
login_button = "Continue"
bad_password = "Your password is incorrect."
network_error = "We could not reach the server."# locales/es.toml
app_title = "Panel de Control Acme"
login_title = "Iniciar sesion"
login_button = "Continuar"
bad_password = "La contrasena es incorrecta."
network_error = "No pudimos conectarnos al servidor."Rules that matter:
- File names become locale identifiers, so
en.tomlloads asen. - Locale identifiers must be simple file stems like
en,en-US, orpt_BR. - Nested TOML tables and non-string values are ignored.
- Keep keys stable and descriptive. Treat them like public API for your UI.
use lang_lib::Lang;
fn configure_i18n() -> Result<(), lang_lib::LangError> {
Lang::set_path("locales");
Lang::load("en")?;
Lang::load("es")?;
Lang::set_fallbacks(vec!["en".to_string()]);
Lang::set_locale("en");
Ok(())
}use lang_lib::{t, Lang};
fn render_login() {
println!("{}", t!("login_title"));
println!("{}", t!("login_button"));
Lang::set_locale("es");
println!("{}", t!("login_title"));
println!("{}", t!("missing_key", fallback: "Default copy"));
}The repository includes a runnable example wired to sample locale files:
cargo run --example basicFor request-driven services, the safest pattern is simple: load locales once at startup, resolve the locale for each request, and pass that locale explicitly when translating.
That means you should usually avoid calling Lang::set_locale inside request
handlers. Lang stores its active locale as process-global state, so changing
it per request creates unnecessary cross-request coupling.
Preferred pattern:
use lang_lib::{resolve_accept_language, Lang};
fn render_for_request(header: &str) -> String {
let locale = resolve_accept_language(header, &["en", "es"], "en");
Lang::translate("login_title", Some(locale), Some("Sign in"))
}Runnable server-oriented example:
cargo run --example serverIf you want less repetition inside handlers, create a request-scoped helper:
use lang_lib::{resolve_accept_language, Lang};
fn render_for_request(header: &str) -> String {
let locale = resolve_accept_language(header, &["en", "es"], "en");
let translator = Lang::translator(locale);
translator.translate_with_fallback("login_title", "Sign in")
}If your application already receives an Accept-Language header, the crate now
includes a small helper for turning that header into one of your supported
locale identifiers.
use lang_lib::resolve_accept_language;
let locale = resolve_accept_language(
"es-ES,es;q=0.9,en;q=0.8",
&["en", "es"],
"en",
);
assert_eq!(locale, "es");The helper prefers higher q values, then exact locale matches, then
primary-language matches like es-ES -> es.
If your supported locales are built at runtime, use the owned variant instead:
use lang_lib::resolve_accept_language_owned;
let supported = vec!["en".to_string(), "es".to_string()];
let locale = resolve_accept_language_owned(
"es-MX,es;q=0.9,en;q=0.7",
&supported,
"en",
);
assert_eq!(locale, "es");In plain terms: this version is for cases where your locale list is not a
hard-coded &["en", "es"], but comes from config or some other runtime data.
Translator is a tiny convenience wrapper around a locale string. It keeps
request-local code readable while still using the safe server-side policy.
use lang_lib::{Lang, Translator};
fn render_page(locale: &str) -> String {
let translator = Translator::new(locale);
translator.translate_with_fallback("dashboard_title", "Dashboard")
}
fn render_page_via_lang(locale: &str) -> String {
let translator = Lang::translator(locale);
translator.translate("dashboard_title")
}This helper does not change Lang::locale(). It only bundles a locale with
repeated translation calls.
The repository also includes a real axum example that plugs locale
resolution into an HTTP handler.
Run it with:
cargo run --example axum_server --features web-example-axumThen request it with different Accept-Language headers:
curl http://127.0.0.1:3000/
curl -H "Accept-Language: es-ES,es;q=0.9" http://127.0.0.1:3000/If you prefer actix-web, the repository includes a matching example:
cargo run --example actix_server --features web-example-actixThe three server-oriented examples share the same locale bootstrap and
Accept-Language parsing helper, so their behavior stays aligned as the
examples evolve.
Plain TOML, one key per string:
# locales/en.toml
bad_password = "Your password is incorrect."
not_found = "The page you requested does not exist."Files are resolved as {path}/{locale}.toml.
Locale identifiers must be simple file stems like en, en-US, or pt_BR.
Path separators and relative path components are rejected before file access.
Lang::set_pathchanges the base directory used byLang::load.Lang::load_fromlets you load a locale from a one-off directory.Lang::set_localechanges the process-wide active locale.Lang::translatorcreates a request-scoped helper for repeated lookups.resolve_accept_languagemaps anAccept-Languageheader to one of your supported locales.resolve_accept_language_owneddoes the same job when your supported locales live inVec<String>or similar runtime data.Lang::set_fallbackscontrols the order used when a key is missing.Lang::loadedreturns a sorted list, which is useful for diagnostics.
When a key is not found, lookup proceeds as follows:
- Requested locale (or active locale)
- Each locale in the fallback chain, in order
- Inline
fallback:value if provided int! - The key string itself — never returns empty
lang-lib keeps failure modes narrow and explicit.
use lang_lib::{Lang, LangError};
match Lang::load("en") {
Ok(()) => {}
Err(LangError::Io { locale, cause }) => {
eprintln!("could not read {locale}: {cause}");
}
Err(LangError::Parse { locale, cause }) => {
eprintln!("invalid TOML in {locale}: {cause}");
}
Err(LangError::InvalidLocale { locale }) => {
eprintln!("rejected invalid locale identifier: {locale}");
}
Err(LangError::NotLoaded { locale }) => {
eprintln!("locale was expected but not loaded: {locale}");
}
}- Load all required locales during startup instead of lazily during request handling.
- Keep one fallback locale with complete coverage, usually
en. - In servers, resolve a locale per request and pass it explicitly instead of mutating the global active locale.
- Treat translation keys as stable identifiers and review changes to them carefully.
- File lookup is cross-platform and uses the platform's native path handling.
- Locale loading rejects path traversal inputs such as
../enor nested paths. - Internal state recovers from poisoned locks instead of panicking on future reads.
Lang::loaded()returns a sorted list for deterministic diagnostics and tests.
The repository includes a small Criterion benchmark that measures two hot paths:
resolve_accept_language- translation lookup through the loaded in-memory store
- fallback-chain lookup when a key is missing in the requested locale
- complete miss with inline fallback string
- complete miss that returns the key itself
Run it with:
cargo bench --bench performanceThis is not a full benchmarking suite, but it gives you repeatable numbers for the operations most likely to matter in a request-driven application.
For interpretation guidance and CI benchmark policy, see BENCHMARKS.md.
The badges at the top of this README point to the CI and benchmark workflows. If they are blank right after adding workflows, trigger the workflows once and GitHub will start showing status immediately.
examples/basic.rs: end-to-end startup and translation flow.examples/server.rs: request-scoped locale resolution for server-side code.examples/axum_server.rs: realaxumhandler using request-scoped translation.examples/actix_server.rs: realactix-webhandler using the same request-scoped policy.examples/common/mod.rs: shared example helper for locale loading and request locale resolution.examples/locales/en.toml: sample English locale file.examples/locales/es.toml: sample Spanish locale file.BENCHMARKS.md: benchmark usage notes, regression guidance, and CI benchmark policy.benches/performance.rs: Criterion benchmark for request locale resolution and translation lookup.
Apache-2.0 — Copyright © 2026 James Gober