pleme-doc-gen 0.1.41

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 TOML AST + pretty-printer.
//!
//! Per the ★★★ NO-format-for-code directive: every TOML emission
//! in pleme-doc-gen flows through this module. Quote-escaping,
//! indent, combinatorial field correctness — all guaranteed by
//! construction.
//!
//! Covers the 7 TOML ecosystems: Cargo / pyproject / Project.toml /
//! fpm.toml / gleam.toml / alire-*.toml + the .pleme-io-release.toml
//! config file. M4.1 ships the AST + migrates render_julia as proof;
//! subsequent commits migrate the other 6.

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

/// A typed TOML value. Either a leaf (string/int/bool/array) or a table.
#[derive(Debug, Clone)]
pub enum Value {
    String(String),
    Int(i64),
    Bool(bool),
    Array(Vec<Value>),
    InlineTable(BTreeMap<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())
    }
    /// Construct a typed inline table — emits `{ k1 = v1, k2 = v2 }`.
    /// Use this for Cargo's inheritance shapes like
    /// `version = { workspace = true }` (which parses identically to
    /// the shorthand `version.workspace = true`).
    pub fn inline_tbl(pairs: impl IntoIterator<Item = (impl Into<String>, Value)>) -> Self {
        Self::InlineTable(pairs.into_iter().map(|(k, v)| (k.into(), v)).collect())
    }

    /// Public wrapper for inline rendering — used when a single Value
    /// needs to be emitted outside a Document context.
    pub fn render_inline(&self) -> String {
        self.render()
    }

    /// Render this value's RHS. Strings get escaped properly; nothing
    /// uses format!() of user input.
    fn render(&self) -> String {
        match self {
            Self::String(s) => render_basic_string(s),
            Self::Int(n) => n.to_string(),
            Self::Bool(true) => "true".to_string(),
            Self::Bool(false) => "false".to_string(),
            Self::Array(items) => {
                let mut out = String::from("[");
                for (i, v) in items.iter().enumerate() {
                    if i > 0 {
                        out.push_str(", ");
                    }
                    out.push_str(&v.render());
                }
                out.push(']');
                out
            }
            Self::InlineTable(map) => {
                let mut out = String::from("{ ");
                for (i, (k, v)) in map.iter().enumerate() {
                    if i > 0 {
                        out.push_str(", ");
                    }
                    out.push_str(&render_bare_key(k));
                    out.push_str(" = ");
                    out.push_str(&v.render());
                }
                out.push_str(" }");
                out
            }
        }
    }
}

/// Typed TOML basic-string rendering with proper escaping. NEVER
/// uses format!() on user input.
fn render_basic_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(r"\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => {
                use std::fmt::Write as _;
                let _ = write!(out, "\\u{:04X}", c as u32);
            }
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

/// Bare keys can be alphanumeric + `-` + `_`. Anything else gets
/// quoted as a basic string.
fn render_bare_key(k: &str) -> String {
    if !k.is_empty()
        && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        k.to_string()
    } else {
        render_basic_string(k)
    }
}

/// A whole TOML document: ordered list of (header-or-none, BTreeMap-of-keys).
#[derive(Debug, Default)]
pub struct Document {
    /// Top-level key-value pairs (no header).
    pub root: BTreeMap<String, Value>,
    /// Named tables in render order.
    pub tables: Vec<Table>,
    /// Array-of-tables blocks: each `[[header]]` entry is a separate Table.
    /// Render order preserved; multiple entries under the same header emit
    /// as repeated `[[header]]` blocks.
    pub table_arrays: Vec<Table>,
}

#[derive(Debug, Default)]
pub struct Table {
    pub header: String,
    pub keys: BTreeMap<String, Value>,
}

impl Document {
    pub fn new() -> Self {
        Self::default()
    }

    /// Set a top-level (no-header) key.
    pub fn root_key(&mut self, k: impl Into<String>, v: Value) -> &mut Self {
        self.root.insert(k.into(), v);
        self
    }

    /// Add (or get) a named table.
    pub fn table(&mut self, header: impl Into<String>) -> &mut Table {
        let header = header.into();
        if let Some(idx) = self.tables.iter().position(|t| t.header == header) {
            return &mut self.tables[idx];
        }
        self.tables.push(Table {
            header,
            keys: BTreeMap::new(),
        });
        self.tables.last_mut().unwrap()
    }

    /// Append a new `[[header]]` array-of-tables entry. Each call adds
    /// a fresh entry; multiple calls with the same header emit as
    /// repeated blocks (TOML semantics for `[[…]]`).
    pub fn array_table(&mut self, header: impl Into<String>) -> &mut Table {
        self.table_arrays.push(Table {
            header: header.into(),
            keys: BTreeMap::new(),
        });
        self.table_arrays.last_mut().unwrap()
    }

    /// Render the whole document to TOML text. NEVER uses format!()
    /// on user input — strings flow through render_basic_string.
    pub fn render(&self) -> String {
        let mut out = String::new();
        // Root keys first
        for (k, v) in &self.root {
            let _ = writeln!(out, "{} = {}", render_bare_key(k), v.render());
        }
        for table in &self.tables {
            if !out.is_empty() && !out.ends_with("\n\n") {
                out.push('\n');
            }
            let _ = writeln!(out, "[{}]", table.header);
            for (k, v) in &table.keys {
                let _ = writeln!(out, "{} = {}", render_bare_key(k), v.render());
            }
        }
        for table in &self.table_arrays {
            if !out.is_empty() && !out.ends_with("\n\n") {
                out.push('\n');
            }
            let _ = writeln!(out, "[[{}]]", table.header);
            for (k, v) in &table.keys {
                let _ = writeln!(out, "{} = {}", render_bare_key(k), v.render());
            }
        }
        out
    }
}

impl Table {
    pub fn key(&mut self, k: impl Into<String>, v: Value) -> &mut Self {
        self.keys.insert(k.into(), v);
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn basic_string_escapes_quotes() {
        assert_eq!(render_basic_string(r#"hello "world""#), r#""hello \"world\"""#);
    }

    #[test]
    fn basic_string_escapes_backslash() {
        assert_eq!(render_basic_string(r"path\to\file"), r#""path\\to\\file""#);
    }

    #[test]
    fn document_renders_table() {
        let mut doc = Document::new();
        doc.table("package").key("name", Value::s("demo")).key("version", Value::s("0.1.0"));
        let out = doc.render();
        assert!(out.contains("[package]"));
        assert!(out.contains(r#"name = "demo""#));
        assert!(out.contains(r#"version = "0.1.0""#));
    }

    #[test]
    fn quotes_in_description_dont_break_output() {
        let mut doc = Document::new();
        doc.table("package").key("description", Value::s(r#"A "quoted" thing"#));
        let out = doc.render();
        assert!(out.contains(r#"description = "A \"quoted\" thing""#));
    }

    #[test]
    fn array_of_tables_renders_double_brackets() {
        let mut doc = Document::new();
        doc.array_table("depends-on").key("gnat", Value::s(">=11"));
        doc.array_table("depends-on").key("alr", Value::s("~1.2"));
        let out = doc.render();
        assert!(out.contains("[[depends-on]]"), "missing [[depends-on]]");
        assert!(out.matches("[[depends-on]]").count() == 2, "should repeat header per entry");
        assert!(out.contains(r#"gnat = ">=11""#), "missing gnat key");
        assert!(out.contains(r#"alr = "~1.2""#), "missing alr key");
    }
}