Rust API & semver policy¶
disarm ships two surfaces from one crate, governed by two independent
stability policies:
| Surface | Where | Stability |
|---|---|---|
| Rust crate | disarm::api + error types |
semver, described below |
| Python package | import disarm |
the pinned Python API (enforced by tests/test_api_stability.py) |
A change can be breaking for one and not the other. The Rust semver version (the
crate version in Cargo.toml) and the Python distribution version are kept in
lockstep numerically, but the guarantees below apply only to the Rust surface.
The public Rust surface¶
The only semver-governed Rust API is:
- the
disarm::apimodule — the idiomatic,pyo3-free function surface, its parameter/builder types (TargetScript,NormalizationForm,UrlComponent,ReverseLang,Platform,SlugConfig,Scheme,OnUnknown,Transliterate,AutoLangInspection,HostnameAnalysis,AnomalyReport,AnomalyKind,Finding, …), and theDisarmStrextension trait (re-exported at the crate root); - the error types
Error,ErrorKind, andErrorMode.
Wide functions use the builder pattern: [Transliterate] collapses the
mutually-exclusive Cyrillic schemes into [Scheme] (so the illegal
iso9 && gost state can't be built) and folds the replacement string into
[OnUnknown]. The public enums are #[non_exhaustive] and implement
FromStr + Display; the no-op-returning transforms (strip_accents,
fold_case, normalize_confusables, …) return Cow<'_, str> and borrow on the
unchanged path. Pure functions are #[must_use].
api::CONFUSABLES_VERSION (and api::confusables_version()) is a special case: the
item is semver-governed like any other, but its value is bundled data and moves
with a table refresh, which the semver policy already allows without a breaking bump.
Do not pin behaviour to a specific value — read it, report it, compare it.
Everything else is an implementation detail and carries no guarantee:
- modules declared
pub(crate)(the Layer-1 algorithm cores); - the three
#[doc(hidden)] pubmodules (emoji,transliterate,tables), exposed only so the in-repo Criterion/iai benchmarks — separate crates that can see justpubitems — can measure the cores directly. They are excluded from docs.rs and fromcargo-semver-checks. Do not depend on them. - the
extension-modulefeature and thedisarm._corePyO3 layer.
If you find yourself reaching past disarm::api, please open an issue — the
missing capability belongs in api.
What counts as a breaking change¶
Following SemVer and the Rust API guidelines, a major bump is required to:
- remove or rename a public
apiitem, or change a function signature; - add a field to a public struct that is not
#[non_exhaustive], or a variant to a non-#[non_exhaustive]enum; - raise the MSRV (see below).
A minor bump covers additive changes: new api functions, new
#[non_exhaustive] enum variants, new struct fields behind #[non_exhaustive].
The public enums (ErrorKind, TargetScript, NormalizationForm, …) are marked
#[non_exhaustive] precisely so new variants are a minor, not major, change —
always include a _ => arm when matching them.
Note: data-driven output is not semver-stable, and this covers the security
surfaces and the key builders too. Transliteration output (Unicode tables,
romanization standards), the confusable/security functions —
normalize_confusables, strip_obfuscation, is_suspicious_hostname, and the
canonicalize* presets — and the three key builders search_key,
catalog_key and sort_key all change behavior when the bundled Unicode /
TR39 tables are updated, with no signature touched. When they are allowed to
change differs: everything named here may move in any release, except the three
key builders, which are confined to minor releases by the contract in the next
section. For example, #336 extended
normalize_confusables with cross-script pairs absent from upstream TR39 17.0,
which changes what a deployed filter chain catches. Such changes are documented
in the changelog but are not treated as semver-breaking. Pin a version if
you need byte-stable output — this applies to security-filter behavior (what
is_suspicious_hostname flags), not just romanization. The bundled data
vintage per release is recorded in provenance.md.
Key stability — what a stored key is worth¶
search_key, catalog_key and sort_key exist to produce a value you store
and compare later, so "not semver-stable" costs more for them than for a function
whose output you look at once. Until #644 the clause above named four other things
and not these three, which left the question open. It is answered here:
A patch release never changes key-builder output. A minor release may.
That is the contract, and it is what a consumer can plan against:
| upgrade | what it means |
|---|---|
0.14.0 → 0.14.3 |
Nothing to do. A key you stored still compares equal. |
0.14.x → 0.15.0 |
Read the changelog's Upgrade notes first. Treat it as a possible reindex until they say otherwise. |
The same rule holds in every binding, because they all wrap one core.
This is what has always happened¶
Measured across every version disarm has published, on 12,285 fixed inputs — the
code points U+0020–U+2FFF plus a word list in 13 scripts — with each release
installed from PyPI into a clean virtualenv:
| transition | search_key |
catalog_key |
sort_key |
|
|---|---|---|---|---|
0.9.0 → 0.9.1 |
patch | 0 | 0 | 0 |
0.9.1 → 0.10.0 |
minor | 0 | 19 | 0 |
0.10.0 → 0.11.0 |
minor | 62 | 73 | 1021 |
0.11.0 → 0.11.1 |
patch | 0 | 0 | 0 |
0.11.1 → 0.12.0 |
minor | 0 | 0 | 0 |
0.12.0 → 0.13.0 |
minor | 0 | 0 | 0 |
0.13.0 → 0.14.0 |
minor | 147 | 148 | 416 |
Both patch releases moved nothing. Three of the five minors moved nothing either — which is the reason a consumer could not tell the difference from outside, and the reason the rule has to be written down rather than inferred.
The rate understates it, because the characters are common¶
0.13.0 → 0.14.0 reads as 1.2% of the 12,285 probes. At word level it is most
of a Cyrillic index, because the Russian soft and hard signs are in the changed
set:
| word | search_key on 0.13.0 |
on 0.14.0 |
|---|---|---|
подъезд |
podъezd |
podezd |
Игорь |
igorь |
igor |
Соловьёв |
solovьyov |
solovyov |
adolf is right and adolьf was wrong — the key builders had stopped applying
134 empty table mappings that transliterate() honours (#602). Nobody should
want that reverted, which is exactly why the cadence matters: a consumer cannot
tell a release that fixes their keys from one that leaves them alone, and both
are correct behaviour.
How the rule is kept¶
By a gate, since #644. tests/test_key_stability.py recomputes eight key-producing
functions over a fixed 22,878-row corpus and fails on any change, reporting a
per-function count and a sample of what moved. Regenerating the fixture is the act
of accepting the change, and it belongs in the same commit as the change that
caused it.
Review alone did not catch this, and the history says so. 0.14.0 moved
search_key on 4.1% of a 5,030-input corpus, and the change responsible (#602)
was a correctness fix whose diff said nothing about keys — it stopped
ErrorMode::Preserve excepting itself from the table's empty mappings. Nobody
reading that diff would have thought reindex.
Checked against the published 0.13.0 wheel, the gate reports the movement it was
built for:
| function | rows changed | share of 22,878 |
|---|---|---|
sort_key |
3,026 | 13.23% |
canonicalize_strict |
604 | 2.64% |
search_key |
267 | 1.17% |
catalog_key |
267 | 1.17% |
canonicalize |
249 | 1.09% |
normalize_confusables |
249 | 1.09% |
strip_obfuscation |
164 | 0.72% |
Every one of those is a correctness fix, which is the point rather than a
complication: banĸ.example really should become bank.example, and it still
invalidates a stored key. The gate does not judge whether a change is right. It
makes the change visible, and forces somebody to decide.
What it still does not give a consumer is a signal they can assert in their own
CI. That is KEY_SCHEMA_VERSION (#645), which is downstream of this fixture: a
constant is only meaningful once something detects that the thing it counts has
moved.
MSRV¶
The minimum supported Rust version is recorded as rust-version in
Cargo.toml. An MSRV increase is a minor-version change and is called out in the
changelog. (Dev-only tooling — benches, cargo test --all-targets — may require
a newer toolchain than the shipped library; that is not part of the MSRV
contract.)
Feature flags¶
| Feature | Default | Purpose |
|---|---|---|
| (none) | ✅ | Pure-Rust core. No pyo3, no libpython. This is what cargo add disarm gives you. |
extension-module |
— | Builds the disarm._core Python extension (pulls in pyo3). Python wheel only — Rust consumers never enable it; a bare cargo build --features extension-module fails to link without an interpreter. |
embed-dicts |
— | Embeds the compiled Arabic/Persian/Hebrew context dictionaries into the binary (otherwise they are loaded at runtime). |
log |
— | Opt-in diagnostic logging via the log facade (#208). OFF by default — the shipped artifact has no logging code in the hot path unless turned on. Records carry only metadata (lengths, lang, mode, flags, counts, durations, error codes) — never input/output text. The sink is the consumer's choice (env_logger, tracing-subscriber, …). A library must not set log's release_max_level_* — that is the application's call. |
log-content |
— | Escape hatch: TRACE-only, possibly-truncated content samples for local debugging. Never enable in production. |
Verifying the published surface¶
# The pure dependency tree must carry no pyo3 (the crates.io core is libpython-free)
cargo tree -e no-dev | grep -qi pyo3 && echo "pyo3 leaked!" || echo "pure core OK"
# What cargo would publish
cargo package --list
# API-compatibility check against the last release
cargo semver-checks check-release