Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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 |
//! |------|--------|
//! | `&`  | `&amp;`  |
//! | `<`  | `&lt;`   |
//! | `>`  | `&gt;`   |
//! | `"`  | `&quot;` |
//!
//! **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 (`&#39;`/`&apos;`) 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 `'`→`&apos;`: 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** `'`→`&apos;` (the XML-canonical
//! spelling, valid in `repomd`; note HTML would want `&#39;`). 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 `&apos;`, which is exactly why this variant exists.
//!
//! Output is byte-identical to the twin's
//! `s.replace('&',"&amp;").replace('<',"&lt;").replace('>',"&gt;").replace('"',"&quot;")`
//! (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 &amp; b &lt; 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 `&`→`&amp;`, `<`→`&lt;`, `>`→`&gt;`, `"`→`&quot;`. 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>"), "&lt;tspan&gt;&quot;x&quot; &amp; y&lt;/tspan&gt;");
/// ```
#[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 `&amp;`
    // 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("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            _ => 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 `&`→`&amp;`, `<`→`&lt;`, `>`→`&gt;`, `"`→`&quot;`, and
/// `'`→`&apos;`. 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.
///
/// `&apos;` is the XML-canonical spelling and is what `repomd` consumers expect;
/// it is valid XML but NOT valid in HTML4 (there `&#39;` 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&apos;s &lt;repo&gt; &amp; &quot;x&quot;");
/// // identical to `xml` for inputs with no apostrophe
/// assert_eq!(xml_attr("a & b"), "a &amp; 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("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&apos;"),
            _ => 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("&"), "&amp;", "ampersand");
        assert_eq!(xml("<"), "&lt;", "less-than");
        assert_eq!(xml(">"), "&gt;", "greater-than");
        assert_eq!(xml("\""), "&quot;", "double-quote");
    }

    /// Apostrophe is deliberately NOT escaped — matches every real twin. If a
    /// future edit "helpfully" adds `'`→`&#39;`, 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 &amp; b &lt; c &gt; d &quot; e");
        // Adjacent + repeated entities.
        assert_eq!(xml("<<&&>>"), "&lt;&lt;&amp;&amp;&gt;&gt;");
    }

    /// 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('&', "&amp;")
                .replace('<', "&lt;")
                .replace('>', "&gt;")
                .replace('"', "&quot;");
            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("&"), "&amp;", "ampersand");
        assert_eq!(xml_attr("<"), "&lt;", "less-than");
        assert_eq!(xml_attr(">"), "&gt;", "greater-than");
        assert_eq!(xml_attr("\""), "&quot;", "double-quote");
        assert_eq!(xml_attr("'"), "&apos;", "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&apos;s");
        assert_eq!(xml_attr("Alice's 'repo'"), "Alice&apos;s &apos;repo&apos;");
    }

    /// 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('&', "&amp;")
                .replace('<', "&lt;")
                .replace('>', "&gt;")
                .replace('"', "&quot;")
                .replace('\'', "&apos;");
            assert_eq!(xml_attr(s), twin, "diverged from twin for {s:?}");
        }
    }
}