crates/nornir-escape/src/lib.rs
//! # nornir-escape
//!
//! The ONE shared **markup-escaping** primitive for the nordisk constellation —
//! turn a raw label into text that is safe to drop into SVG/XML markup.
//!
//! ## Why it exists (LAW #5 — DRY)
//! **Seven byte-identical** `xml_escape` / `svg_escape` copies grew independently
//! across four repos — every one escapes exactly the same four entities with the
//! same `.replace()` chain:
//!
//! | site | name | entities |
//! |------|------|----------|
//! | `nornir` `src/viz/diagram.rs` | `xml_escape` | `& < > "` |
//! | `nornir` `src/arch/mod.rs` | `xml_escape` | `& < > "` |
//! | `nornir` coverage-metrics SVG emitter (site since removed) | `xml_escape` | `& < > "` |
//! | `nornir` `src/mimir.rs` | `svg_escape` | `& < > "` |
//! | `nornir` `crates/nornir-introspect/src/lib.rs` | `xml_escape` | `& < > "` |
//! | `edda` `crates/nornir-testmatrix/src/coverage.rs` | `svg_escape` | `& < > "` |
//! | `Skidbladnir` `src/msi.rs` | `xml_escape` | `& < > "` |
//!
//! Every one round-trips a label into safe markup — pure, dep-free, no domain
//! state. This leaf owns the **canon** so the whole constellation escapes the
//! same, and a third unrelated SVG/XML emitter gets it as-is (`nornir-testmatrix`
//! already keeps a "local copy so this module stays PURE std" — it is now this
//! leaf).
//!
//! ## The canonical entity set (the majority — in fact *unanimous* — twin)
//! [`xml`] escapes the **four** entities every real twin escapes, byte-for-byte:
//!
//! | char | entity |
//! |------|--------|
//! | `&` | `&` |
//! | `<` | `<` |
//! | `>` | `>` |
//! | `"` | `"` |
//!
//! **Apostrophe (`'`) is deliberately NOT escaped** by [`xml`] — zero of the
//! seven SVG twins escape it (they emit SVG with double-quoted attributes, where
//! a bare `'` is legal). Escaping it (`'`/`'`) would break byte-parity
//! with those consumers and could trip the docs-render regression guard, so
//! [`xml`] intentionally omits it.
//!
//! ## The apostrophe-inclusive variant — [`xml_attr`]
//! A real twin *does* need `'`→`'`: repository-metadata XML (holger's
//! znippy-rpm `repomd.xml`) uses single-quoted attributes, where a literal `'`
//! is not legal. [`xml_attr`] is the **five-entity** escaper for that case —
//! everything [`xml`] escapes **plus** `'`→`'` (the XML-canonical
//! spelling, valid in `repomd`; note HTML would want `'`). It is a distinct
//! function, not a change to [`xml`]'s contract, so no existing SVG consumer is
//! disturbed — swapping a `repomd` emitter onto [`xml`] would silently regress
//! its `'`, which is exactly why this variant exists.
//!
//! Output is byte-identical to the twin's
//! `s.replace('&',"&").replace('<',"<").replace('>',">").replace('"',""")`
//! (the replacements are order-independent — none introduces a char a later one
//! matches — so a single pass over the original string reproduces them exactly).
//!
//! ## What STAYS in the conductor (L5a — leaf vs conductor)
//! Only the generic, context-pure XML/SVG escaper is a twin. The **domain-coupled**
//! escapers stay where they are:
//! * nornir's `escape_typst` / `escape_typst_text` / `escape_typst_str` — Typst
//! escaping, and in particular the markdown→typst `//`/`/*` comment-avoidance
//! logic, is docs-pipeline domain, and the three diverge (no twin).
//! * nornir's `md_escape` (`|` + `` ` ``) / `cell` (`|`) — markdown-table escaping,
//! single-site and divergent (no twin).
//! * nornir's `sanitize_asset` — an asset-filename slug, not an escaper.
//!
//! ## Dep-light (L5a)
//! std-only — **zero** runtime dependencies (the sole `Cargo.toml` entry,
//! `nornir-testmatrix`, is optional and compiled out of any default build).
//! [`Cow`] is returned so a clean input borrows with **no allocation**.
//!
//! ```
//! use nornir_escape::xml;
//! use std::borrow::Cow;
//! assert_eq!(xml("a & b < c"), "a & b < c");
//! assert_eq!(xml("plain label"), "plain label");
//! // clean input is borrowed — no allocation
//! assert!(matches!(xml("clean"), Cow::Borrowed(_)));
//! // apostrophes pass through unescaped (matches every twin)
//! assert_eq!(xml("Alice's repo"), "Alice's repo");
//! ```
use std::borrow::Cow;
pub mod selftest;
/// XML/SVG-escape a label for safe inclusion in SVG/XML **text or
/// double-quoted attributes** — the canonical four-entity escaper every twin
/// across the constellation spells identically.
///
/// Escapes `&`→`&`, `<`→`<`, `>`→`>`, `"`→`"`. The apostrophe
/// `'` is left as-is (see the crate-level docs for why).
///
/// Returns a borrowed [`Cow`] with **no allocation** when the input contains
/// none of the four; only a string that actually needs escaping is copied.
///
/// ```
/// # use nornir_escape::xml;
/// assert_eq!(xml("<tspan>\"x\" & y</tspan>"), "<tspan>"x" & y</tspan>");
/// ```
#[inline]
pub fn xml(s: &str) -> Cow<'_, str> {
// Fast path: these are all ASCII bytes that never occur inside a UTF-8
// multibyte sequence, so a byte scan is correct and allocation-free.
if !s.bytes().any(|b| matches!(b, b'&' | b'<' | b'>' | b'"')) {
return Cow::Borrowed(s);
}
// Slow path: one pass over the original string. `&` is expanded to `&`
// and never re-scanned, exactly reproducing the twin's `&`-first replace chain.
let mut out = String::with_capacity(s.len() + 16);
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
Cow::Owned(out)
}
/// XML-escape a label for safe inclusion in XML text **or single-quoted
/// attributes** — the five-entity superset of [`xml`] that additionally escapes
/// the apostrophe.
///
/// Escapes `&`→`&`, `<`→`<`, `>`→`>`, `"`→`"`, and
/// `'`→`'`. Use this (not [`xml`]) for XML that quotes attributes with `'`
/// — e.g. holger's znippy-rpm `repomd.xml`, where a literal apostrophe inside a
/// single-quoted attribute is not legal.
///
/// `'` is the XML-canonical spelling and is what `repomd` consumers expect;
/// it is valid XML but NOT valid in HTML4 (there `'` is the portable form),
/// so keep this for XML sinks only.
///
/// Returns a borrowed [`Cow`] with **no allocation** when the input contains
/// none of the five.
///
/// ```
/// # use nornir_escape::xml_attr;
/// assert_eq!(xml_attr("Alice's <repo> & \"x\""), "Alice's <repo> & "x"");
/// // identical to `xml` for inputs with no apostrophe
/// assert_eq!(xml_attr("a & b"), "a & b");
/// ```
#[inline]
pub fn xml_attr(s: &str) -> Cow<'_, str> {
if !s.bytes().any(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\'')) {
return Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len() + 16);
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
Cow::Owned(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// Each of the four entities maps to EXACTLY the twin's spelling. This is the
/// RED-when-broken gate: neutralize any single mapping in [`xml`] and the
/// matching row here fails.
#[test]
fn each_entity_exact_spelling() {
assert_eq!(xml("&"), "&", "ampersand");
assert_eq!(xml("<"), "<", "less-than");
assert_eq!(xml(">"), ">", "greater-than");
assert_eq!(xml("\""), """, "double-quote");
}
/// Apostrophe is deliberately NOT escaped — matches every real twin. If a
/// future edit "helpfully" adds `'`→`'`, this red-guards it.
#[test]
fn apostrophe_passes_through() {
assert_eq!(xml("it's"), "it's");
assert_eq!(xml("Alice's 'repo'"), "Alice's 'repo'");
}
/// A clean string is borrowed — proving the no-allocation fast path.
#[test]
fn clean_input_borrows() {
assert!(matches!(xml("plain label 123"), Cow::Borrowed(_)));
assert!(matches!(xml(""), Cow::Borrowed(_)));
// A string that DOES need escaping is owned.
assert!(matches!(xml("a<b"), Cow::Owned(_)));
}
#[test]
fn empty_and_mixed() {
assert_eq!(xml(""), "");
assert_eq!(xml("a & b < c > d \" e"), "a & b < c > d " e");
// Adjacent + repeated entities.
assert_eq!(xml("<<&&>>"), "<<&&>>");
}
/// Byte-for-byte identical to the twin's `.replace()` chain — the invariant
/// that makes every repoint safe. Property-checked over a mixed corpus.
#[test]
fn matches_replace_chain_exactly() {
for s in [
"",
"clean",
"a & b",
"<tag attr=\"v & w\">x</tag>",
"Alice's <bench> & \"baseline\"",
"no specials but unicode: café — ①②③ 日本語",
"&<>\"'&<>\"'",
] {
let twin = s
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """);
assert_eq!(xml(s), twin, "diverged from twin for {s:?}");
}
}
/// Unicode text with no specials must borrow, not copy.
#[test]
fn unicode_clean_borrows() {
let s = "café — 日本語 ①②③";
assert!(matches!(xml(s), Cow::Borrowed(_)));
assert_eq!(xml(s), s);
}
// ─── xml_attr (five-entity, apostrophe-inclusive) ────────────────────────
/// Each of the FIVE entities maps to exactly its XML-canonical spelling —
/// the RED-when-broken gate for `xml_attr`. The apostrophe row is the one
/// that separates it from [`xml`].
#[test]
fn attr_each_entity_exact_spelling() {
assert_eq!(xml_attr("&"), "&", "ampersand");
assert_eq!(xml_attr("<"), "<", "less-than");
assert_eq!(xml_attr(">"), ">", "greater-than");
assert_eq!(xml_attr("\""), """, "double-quote");
assert_eq!(xml_attr("'"), "'", "apostrophe");
}
/// The whole point: `xml_attr` escapes `'` where `xml` leaves it literal.
/// Neutralize the apostrophe arm and this fails while the `xml` twin stays
/// green — proving the two contracts are distinct.
#[test]
fn attr_escapes_apostrophe_where_xml_does_not() {
assert_eq!(xml("it's"), "it's");
assert_eq!(xml_attr("it's"), "it's");
assert_eq!(xml_attr("Alice's 'repo'"), "Alice's 'repo'");
}
/// Clean input borrows (no allocation); anything needing an escape — an
/// apostrophe included — is owned.
#[test]
fn attr_clean_borrows_dirty_owns() {
assert!(matches!(xml_attr("plain label 123"), Cow::Borrowed(_)));
assert!(matches!(xml_attr(""), Cow::Borrowed(_)));
assert!(matches!(xml_attr("a'b"), Cow::Owned(_)));
assert!(matches!(xml_attr("a&b"), Cow::Owned(_)));
}
/// On inputs with no apostrophe, `xml_attr` is byte-identical to `xml` — it
/// is a strict superset, adding only the `'` mapping.
#[test]
fn attr_superset_of_xml() {
for s in [
"",
"clean",
"<tag attr=\"v & w\">x</tag>",
"a & b < c > d \" e",
"café — 日本語",
] {
assert_eq!(xml_attr(s), xml(s), "diverged from xml (no-apos input) for {s:?}");
}
}
/// Byte-for-byte identical to the five-`.replace()` chain a repomd emitter
/// would otherwise spell by hand.
#[test]
fn attr_matches_replace_chain_exactly() {
for s in [
"",
"Alice's <bench> & \"baseline\"",
"attr='v' & other=\"w\"",
"&<>\"'&<>\"'",
] {
let twin = s
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'");
assert_eq!(xml_attr(s), twin, "diverged from twin for {s:?}");
}
}
}