pleme-doc-gen 0.1.9

Rust replacement for the M0 Python _gen-patterns.py + _gen-docs.py scripts in pleme-io/actions. Walks every action.yml + emits substrate's patterns-full.nix + per-action README.md + root catalog. Per the NO-SHELL prime directive.
//! Typed XML AST + pretty-printer.
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Used by the
//! 2 XML ecosystems (java-maven pom.xml + dotnet-csproj).

use std::collections::BTreeMap;
use std::fmt::Write;

/// XML element node. Children preserve insertion order; attributes
/// alphabetize for stable diffs.
#[derive(Debug, Clone)]
pub struct Element {
    pub name: String,
    pub attrs: BTreeMap<String, String>,
    pub children: Vec<Child>,
}

#[derive(Debug, Clone)]
pub enum Child {
    Element(Element),
    Text(String),
}

impl Element {
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            attrs: BTreeMap::new(),
            children: Vec::new(),
        }
    }
    pub fn attr(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
        self.attrs.insert(k.into(), v.into());
        self
    }
    pub fn text(mut self, t: impl Into<String>) -> Self {
        self.children.push(Child::Text(t.into()));
        self
    }
    pub fn child(mut self, c: Element) -> Self {
        self.children.push(Child::Element(c));
        self
    }
    pub fn push(&mut self, c: Element) -> &mut Self {
        self.children.push(Child::Element(c));
        self
    }
}

fn escape_text(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            c => out.push(c),
        }
    }
    out
}

fn escape_attr(s: &str) -> String {
    let mut out = escape_text(s);
    out = out.replace('"', "&quot;").replace('\'', "&apos;");
    out
}

fn render_at(el: &Element, indent: usize, out: &mut String) {
    let pad = "  ".repeat(indent);
    let _ = write!(out, "{pad}<{}", el.name);
    for (k, v) in &el.attrs {
        let _ = write!(out, " {k}=\"{}\"", escape_attr(v));
    }
    if el.children.is_empty() {
        out.push_str(" />\n");
        return;
    }
    // Single-text child fits on one line.
    if el.children.len() == 1 {
        if let Some(Child::Text(t)) = el.children.first() {
            let _ = write!(out, ">{}</{}>\n", escape_text(t), el.name);
            return;
        }
    }
    out.push_str(">\n");
    for child in &el.children {
        match child {
            Child::Element(e) => render_at(e, indent + 1, out),
            Child::Text(t) => {
                let inner_pad = "  ".repeat(indent + 1);
                let _ = writeln!(out, "{inner_pad}{}", escape_text(t));
            }
        }
    }
    let _ = writeln!(out, "{pad}</{}>", el.name);
}

/// Render a top-level element with the XML declaration prepended.
pub fn render_document(el: &Element) -> String {
    let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
    render_at(el, 0, &mut out);
    out
}

/// Render a top-level element without the XML declaration — for
/// formats like `.csproj` that omit `<?xml … ?>` by convention.
pub fn render_doc_no_decl(el: &Element, out: &mut String) {
    render_at(el, 0, out);
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn text_escapes_lt_gt_amp() {
        assert_eq!(escape_text("a < b & c > d"), "a &lt; b &amp; c &gt; d");
    }
    #[test]
    fn attr_escapes_quote() {
        assert_eq!(escape_attr(r#"a"b"#), "a&quot;b");
    }
    #[test]
    fn empty_element_self_closes() {
        let e = Element::new("dep").attr("name", "x");
        let mut out = String::new();
        render_at(&e, 0, &mut out);
        assert_eq!(out, "<dep name=\"x\" />\n");
    }
    #[test]
    fn single_text_inline() {
        let e = Element::new("name").text("widget");
        let mut out = String::new();
        render_at(&e, 0, &mut out);
        assert_eq!(out, "<name>widget</name>\n");
    }
}