Rust Applications (Experimental)
Use Palamedes to prepare PO catalogs during a Cargo build, then load the selected language from a file when your Rust CLI or service runs. Every language, including English, stays outside the executable. Compatible translation edits and additional languages can ship without rebuilding it.
Page outline
This is an experimental repository integration. palamedes-build and
palamedes-i18n are not published on crates.io; the publication hold in
#1153 still applies.
The current subset is text, display arguments, string selects, and cardinal and
ordinal plurals. Localized number/date/time formatting, rich text, and translation
contexts are rejected. This path does not yet offer the full TypeScript workflow or a stable
Rust SDK contract. See the stability guide.
Build a Small CLI
Create a project with this layout:
greeting-cli/
Cargo.toml
build.rs
locales/
en.po
de.po
src/
main.rsUse a Rust toolchain compatible with the workspace's rust-version in
Cargo.toml. Pin both Git dependencies to the same reviewed
revision. This example pins the implementation used by this guide:
Cargo.toml
[package]
name = "greeting-cli"
version = "0.1.0"
edition = "2024"
[dependencies]
palamedes-i18n = { git = "https://github.com/sebastian-software/palamedes", rev = "439f1bfaf28fca9792ea9e95359e3a95116fb913" }
[build-dependencies]
palamedes-build = { git = "https://github.com/sebastian-software/palamedes", rev = "439f1bfaf28fca9792ea9e95359e3a95116fb913" }Inside the Palamedes workspace, path dependencies are also possible. There is no Node dependency in this Cargo workflow.
locales/en.po
msgid "Hello {name}!"
msgstr "Hello {name}!"locales/de.po
msgid "Hello {name}!"
msgstr "Hallo {name}!"The source-language PO defines the complete message inventory and is always an
input. .locales() chooses which language files to emit; it does not require
shipping an English file. Target catalogs may omit entries or contain empty or
fuzzy translations. By default, those entries compile through .fallback_locales()
and ultimately the source locale/message. Cargo reports fallback use; other build
hosts can inspect CompiledCatalogs::missing. Set .fail_on_missing(true) to reject
incomplete target translations. Invalid or unsupported ICU and argument mismatches
still fail compilation. A fallback is compiled into the selected language file;
no source text or fallback language is embedded in the executable.
build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
let catalogs = palamedes_build::CatalogBuilder::new("locales", "en")
.locales(&["en", "de"])
.build()?;
catalogs.install_to(std::path::Path::new("dist/translations"))?;
std::fs::copy(&catalogs.schema_file, "dist/schema.pmd.json")?;
Ok(())
}build() writes generated definitions to OUT_DIR/messages.rs, an exported
message contract to OUT_DIR/schema.pmd.json, and language data to
OUT_DIR/catalogs/<locale>.pmd.json. Keep the schema with your translation-build
inputs; it contains IDs and argument signatures, not translations. The running
application only needs its selected language file. Cargo tracks the input catalogs
and runs this build script when they change. install_to() copies the language
files to a distribution directory chosen by your application. Here that is
dist/translations/ relative to the package root; the build script owns this
output directory, so use a location appropriate to your packaging workflow.
src/main.rs
use std::path::PathBuf;
include!(concat!(env!("OUT_DIR"), "/messages.rs"));
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = std::env::args().skip(1);
let locale = arguments.next().unwrap_or_else(|| "en".into());
let directory = PathBuf::from(
arguments.next().unwrap_or_else(|| "dist/translations".into()),
);
let catalog = palamedes_i18n::Catalog::load(
directory.join(format!("{locale}.pmd.json")),
&locale,
CATALOG_SCHEMA,
)?;
println!("{}", translate!(&catalog, "Hello {name}!", name = "Ada")?);
Ok(())
}Run it from the project directory:
cargo run -- de
# Hallo Ada!
cargo run -- en
# Hello Ada!English is the application's default locale in this example. It still loads
en.pmd.json from disk. A missing English catalog is an error, just like a
missing German one.
Deploy the Executable and Its Language Files
Build the release executable and copy the languages you want to distribute. This POSIX shell example installs only German into a temporary directory:
cargo build --release
install_dir="$(mktemp -d)"
mkdir -p "$install_dir/translations"
cp target/release/greeting-cli "$install_dir/"
cp dist/translations/de.pmd.json "$install_dir/translations/"
(cd "$install_dir" && ./greeting-cli de translations)
# Hallo Ada!Windows builds produce greeting-cli.exe; copy it and the catalog files with
your platform's packaging tools. Neither installation needs Node, PO files, a
Palamedes checkout, or an English language file to render German.
The application owns locale selection and path discovery. A CLI might inspect
LC_ALL, LC_MESSAGES, and LANG; a service might resolve a locale per request.
The runtime does not read headers or environment variables or search directories.
Pass the selected locale and file path explicitly. Catalogs are immutable, so a
service can keep independent instances per locale and share them across threads.
Catalog::from_bytes(bytes, locale, CATALOG_SCHEMA) supports other storage paths.
What the Generator and Rust Compiler Each Do
- Cargo invokes
build.rsas part ofcargo build.palamedes-builduses Ferrocat's catalog compiler and Palamedes's shared message lowering to prepare language data and generate a schema plus a realmacro_rules! translatedefinition. include!makes those definitions available to your Rust program. Rust's own compiler expandstranslate!calls. It resolves source literals to internal message IDs during constant evaluation and checks named arguments.- The executable loads one prepared catalog and renders its instructions. The runtime decodes JSON data; it contains no PO or ICU parser. No language catalog or source-text rendering fallback is embedded in the executable.
The generator emits one sorted source/signature table and a shared const function
that searches it by binary search. Each macro invocation expands a small lookup,
not a copy of the whole catalog. Both table lookup and argument validation happen
during constant evaluation; the table is not runtime translation storage. If the
generated definitions live inside a module, set .module_path("crate::messages")
to that module's crate-relative path so calls from other modules resolve correctly.
There is no source-rewriting command to run before Cargo. The generator produces
definitions; the normal Rust compiler processes macro invocations. Arguments
can appear in any order. Unknown source messages and missing, extra, or duplicate
argument names fail compilation. A plural or ordinal argument also requires a
built-in integer/float type or a validated PluralNumber; bare strings and other
Display values fail compilation in those positions. Constant messages return Result<&str, Error>;
messages with arguments return Result<String, Error>. Nonplural argument values
implement Display and are inserted literally. Numeric expressions are borrowed
and evaluated once, just like other arguments.
The checked Rust CLI example also shows string selects, plurals, and three languages. Its deployment test copies the executable and selected catalogs into an isolated directory, compiles real PO updates against the exported contract, and adds a compatible language. It checks that the copied executable stays byte-for-byte unchanged.
Plural Messages
Add a complete ICU plural message to the source PO, rather than joining a count
and a translated noun in Rust. For example, append to locales/en.po:
msgid "{count, plural, one {# file} other {# files}}"
msgstr "{count, plural, one {# file} other {# files}}"Append its translation to locales/de.po:
msgid "{count, plural, one {# file} other {# files}}"
msgstr "{count, plural, one {# Datei} other {# Dateien}}"Then add a call in main(). Adding a message changes the contract, so rebuild
and redeploy the executable together with its catalogs:
let count = 2_usize;
println!(
"{}",
translate!(&catalog, "{count, plural, one {# file} other {# files}}", count = count)?
);
// With the German catalog: 2 Dateienplural uses the selected catalog locale's CLDR cardinal rules; selectordinal
uses its ordinal rules. ICU4X caches the required rule sets when the catalog
loads. Its compiled CLDR rule data is part of the runtime dependency; translated
messages remain external. Regional locales such as pt-PT and pt-BR retain
their own rules. Supply the categories your translation needs: zero, one,
two, few, many, and the required other. A missing category selects other.
Exact branches such as =0 match the original count before category selection.
With offset:1, category selection and # use the count minus one. A # inside
a nested select keeps the enclosing plural's value; a nested plural supplies
its own value. An apostrophe-quoted '#' remains literal text.
The generated signature distinguishes display arguments from numeric plural and
ordinal arguments. Built-in integers and floating-point values, including borrowed
values, work directly. A call such as count = "drei" fails Rust compilation,
as do bare decimal strings. Ordinary text and select arguments still accept
Display values.
For decimal input that must preserve visible fractional zeros, parse it into
PluralNumber before calling the macro:
let count: palamedes_i18n::PluralNumber = "1.0".parse()?;
println!(
"{}",
translate!(&catalog, "{count, plural, one {# file} other {# files}}", count = count)?
);PluralNumber validates the signed plain decimal and preserves its visible
digits. English "1" selects one, while "1.0" selects other. A floating-point
value whose Rust display is 1 consequently selects one. The numeric range
remains at most 18 fractional digits and an i128 coefficient, including after
offset subtraction. Invalid decimal input fails parsing. Non-finite floats,
exponent notation, out-of-range numeric values, and arithmetic overflow can still
return Error::PluralArgument at rendering time; type checking cannot prove those
value constraints. Counts are never rounded through f64.
Translations and independent catalog updates cannot introduce a plural for an argument that the source message only uses as display/select text. Such a change would impose a new numeric requirement on existing callers, so compilation and catalog loading reject it. Translations may simplify a numeric argument to plain display without weakening the source contract.
# emits the adjusted value as plain decimal text, preserving fractional zeros.
It does not add localized grouping, separators, or digits. {count, number} and
number/date/time styles remain unsupported; format those values in the host
application when needed.
Update Translations Without Building the Application
Use a matching native pmds build from the pinned repository revision; the
published 1.x CLI does not have this experimental command. Its own
external UI catalogs must be installed alongside it.
The update command itself does not invoke Cargo or rustc for your application.
After the initial application build, change the greeting entry in locales/de.po
to the following, keeping any other entries:
msgid "Hello {name}!"
msgstr "Guten Tag {name}!"Compile that PO file against the already exported contract:
pmds catalog compile --target rust \
--schema dist/schema.pmd.json \
--catalog-dir locales --locale de \
--output dist/translationsThis writes only de.pmd.json, checks the schema fingerprint and full message
contract, and leaves other installed locales in the output directory alone.
Keep en.po as the source-inventory input. Repeat --locale for multiple output
languages; a new language uses the same workflow with its own <locale>.po file.
Use --fallback-locale to configure the build-time chain, --fail-on-missing for
strict completeness, or --json for the output paths and missing-translation
report. Invalid or changed contracts fail before any output catalog is replaced.
Each accepted language file is replaced atomically.
With the install_dir created in the deployment example, install the new data
and run the same copied executable:
cp dist/translations/de.pmd.json "$install_dir/translations/"
(cd "$install_dir" && ./greeting-cli de translations)
# Guten Tag Ada!No application build belongs between those two runs. The equivalent Rust tooling
API loads CatalogContract::load(schema_path) and calls
CatalogBuilder::compile_updates(&contract, output_directory).
Catalog Compatibility and Failures
Plural and pound instructions use compiled format version 2. Version 1 artifacts
and exported schemas must be regenerated with the matching builder, runtime,
and pmds implementation. Upgrade the executable and its catalogs together once;
compatible PO updates can then continue using the exported version 2 schema.
Each catalog carries a format version, locale, and fingerprint of message IDs and argument signatures. Loading checks those fields, the complete message inventory, and the prepared instruction signatures. The Rust data format is separate from the JavaScript catalog ABI.
You can replace translated text or add a locale without rebuilding the executable as long as the message contract stays compatible. Generate new language files from the same message inventory and signatures using the exported schema. The source PO remains an input even when no source-language file is emitted. Adding or removing messages or changing their argument contract requires matching catalogs and an application build.
A missing, corrupt, wrong-locale, or incompatible file returns an error.
Catalog::load() attaches the file path to all failures and distinguishes format
version, locale, and contract-fingerprint mismatches. There is no automatic retry in English and no source-text substitution. The small example
above exits through Rust's normal Result error reporting. A production host
should choose its own bootstrap diagnostic and exit or request-error policy.
Extraction and Current Limits
palamedes_build::extract_messages(source, macro_name) extracts source literals
and line numbers from Rust token trees, including calls inside attributes. It
ignores comments and ordinary string contents and rejects dynamic source
expressions. It is an API for Rust tooling. pmds extract does not yet extract
Rust sources; its existing inputs remain JavaScript, TypeScript, and MDX.
The small example above maintains its PO entries directly.
| Message feature | Experimental Rust support |
|---|---|
| Literal text | Supported |
| Named display arguments | Supported; names checked, numeric plural types enforced by Rust |
| String selects | Supported |
| Plurals and ordinal plurals | Supported; CLDR categories, exact branches, offsets, and # |
| Localized numbers, dates, and times | Rejected at build time |
| Rich text and translation contexts | Rejected at build time |
Palamedes's own pmds CLI uses this integration for external English, German, Spanish, and
French catalogs. Its environment precedence, installation layout, and diagnostic
boundaries are documented in CLI output language.
Those are policies of pmds, not defaults imposed on your application.