pleme-doc-gen 0.1.40

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 JSON AST + pretty-printer.
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Used by the
//! 5 JSON ecosystems (npm / composer / deno / vcpkg / pnpm).

use std::fmt::Write;

/// Ordered JSON value — preserves insertion order for stable diffs.
#[derive(Debug, Clone)]
pub enum Value {
    String(String),
    Int(i64),
    Bool(bool),
    Null,
    Array(Vec<Value>),
    Object(Vec<(String, Value)>),
}

impl Value {
    pub fn s(v: impl Into<String>) -> Self { Self::String(v.into()) }
    pub fn i(v: i64) -> Self { Self::Int(v) }
    pub fn b(v: bool) -> Self { Self::Bool(v) }
    pub fn arr(vs: impl IntoIterator<Item = Value>) -> Self {
        Self::Array(vs.into_iter().collect())
    }
    pub fn obj() -> Self { Self::Object(Vec::new()) }

    /// Insert into object value. Idempotent on key replace.
    pub fn insert(&mut self, k: impl Into<String>, v: Value) -> &mut Self {
        if let Self::Object(items) = self {
            let key = k.into();
            if let Some(idx) = items.iter().position(|(ek, _)| ek == &key) {
                items[idx].1 = v;
            } else {
                items.push((key, v));
            }
        }
        self
    }

    fn render(&self, indent: usize) -> String {
        let pad = "  ".repeat(indent);
        let inner_pad = "  ".repeat(indent + 1);
        match self {
            Self::String(s) => render_string(s),
            Self::Int(n) => n.to_string(),
            Self::Bool(true) => "true".into(),
            Self::Bool(false) => "false".into(),
            Self::Null => "null".into(),
            Self::Array(items) if items.is_empty() => "[]".into(),
            Self::Array(items) => {
                let mut out = String::from("[\n");
                for (i, v) in items.iter().enumerate() {
                    out.push_str(&inner_pad);
                    out.push_str(&v.render(indent + 1));
                    if i + 1 < items.len() {
                        out.push(',');
                    }
                    out.push('\n');
                }
                out.push_str(&pad);
                out.push(']');
                out
            }
            Self::Object(items) if items.is_empty() => "{}".into(),
            Self::Object(items) => {
                let mut out = String::from("{\n");
                for (i, (k, v)) in items.iter().enumerate() {
                    out.push_str(&inner_pad);
                    out.push_str(&render_string(k));
                    out.push_str(": ");
                    out.push_str(&v.render(indent + 1));
                    if i + 1 < items.len() {
                        out.push(',');
                    }
                    out.push('\n');
                }
                out.push_str(&pad);
                out.push('}');
                out
            }
        }
    }
}

/// JSON string escape — covers ", \, control chars, unicode.
fn render_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str(r"\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{08}' => out.push_str("\\b"),
            '\u{0C}' => out.push_str("\\f"),
            c if (c as u32) < 0x20 => {
                let _ = write!(out, "\\u{:04X}", c as u32);
            }
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

/// Render a value at depth 0 with trailing newline.
pub fn render(v: &Value) -> String {
    let mut s = v.render(0);
    s.push('\n');
    s
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn string_escapes_quotes_and_backslashes() {
        assert_eq!(render_string(r#"a"b\c"#), r#""a\"b\\c""#);
    }
    #[test]
    fn object_renders_with_indent() {
        let mut o = Value::obj();
        o.insert("name", Value::s("demo"));
        o.insert("version", Value::s("0.1.0"));
        let out = render(&o);
        assert!(out.contains(r#""name": "demo""#));
        assert!(out.contains(r#""version": "0.1.0""#));
    }
    #[test]
    fn nested_object_indented() {
        let mut outer = Value::obj();
        let mut inner = Value::obj();
        inner.insert("type", Value::s("git"));
        outer.insert("repository", inner);
        let s = render(&outer);
        assert!(s.contains(r#""type": "git""#));
    }
}