pleme-doc-gen 0.1.52

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 debian-control-style AST + pretty-printer.
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Used by the
//! control-style ecosystems: R's DESCRIPTION + Haskell's .cabal file
//! + Debian control + RPM spec headers + any future "Key: value with
//! indented continuation lines and optional indented sub-stanzas"
//! grammar.
//!
//! The shape:
//!   Key: value
//!   Stanza
//!     sub-key: sub-value
//!
//! No quoting (values are bare strings); colon-separator; indented
//! continuation is a single-space prefix in debian-control proper +
//! 2-space prefix in Cabal — the renderer takes the indent width as
//! a parameter.

use std::fmt::Write;

#[derive(Debug, Clone)]
pub enum Line {
    /// A bare comment — emitted with leading `-- ` (Haskell-flavor).
    Comment(String),
    /// A blank line — separates stanza groups.
    Blank,
    /// `Key: value` field. Value is rendered as-is (no quoting; control
    /// format treats the entire RHS as a string).
    Field { key: String, value: String },
    /// `Header\n  body…` — a named stanza with indented child lines.
    /// Body lines are indented `indent_width` spaces per the format.
    Stanza { header: String, body: Vec<Line> },
}

pub fn render(lines: &[Line], indent_width: usize) -> String {
    let mut out = String::new();
    render_at(lines, 0, indent_width, &mut out);
    out
}

/// Typed wrapper bundling the lines with the format's indent-width
/// (1 for debian-control, 2 for Cabal). Implements `ast::Render` so
/// the unified surface works without per-call indent arguments.
#[derive(Debug, Clone)]
pub struct Document {
    pub lines: Vec<Line>,
    pub indent_width: usize,
}

impl Document {
    pub fn new(indent_width: usize) -> Self {
        Self { lines: Vec::new(), indent_width }
    }
    pub fn from(indent_width: usize, lines: impl IntoIterator<Item = Line>) -> Self {
        Self { lines: lines.into_iter().collect(), indent_width }
    }
    pub fn push(&mut self, line: Line) -> &mut Self { self.lines.push(line); self }
}

fn render_at(lines: &[Line], depth: usize, iw: usize, out: &mut String) {
    let pad = " ".repeat(depth * iw);
    for line in lines {
        match line {
            Line::Comment(c) => {
                let _ = writeln!(out, "{pad}-- {c}");
            }
            Line::Blank => out.push('\n'),
            Line::Field { key, value } => {
                let _ = writeln!(out, "{pad}{key}: {value}");
            }
            Line::Stanza { header, body } => {
                let _ = writeln!(out, "{pad}{header}");
                render_at(body, depth + 1, iw, out);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn field_emits_key_colon_value() {
        let out = render(
            &[Line::Field { key: "Package".into(), value: "demo".into() }],
            2,
        );
        assert_eq!(out, "Package: demo\n");
    }
    #[test]
    fn stanza_indents_body_by_iw() {
        let out = render(
            &[Line::Stanza {
                header: "library".into(),
                body: vec![
                    Line::Field { key: "exposed-modules".into(), value: "Lib".into() },
                    Line::Field { key: "build-depends".into(), value: "base".into() },
                ],
            }],
            2,
        );
        assert!(out.starts_with("library\n"));
        assert!(out.contains("  exposed-modules: Lib\n"));
        assert!(out.contains("  build-depends: base\n"));
    }
    #[test]
    fn blank_line_separates() {
        let out = render(
            &[
                Line::Field { key: "Name".into(), value: "x".into() },
                Line::Blank,
                Line::Field { key: "Version".into(), value: "1".into() },
            ],
            1,
        );
        assert_eq!(out, "Name: x\n\nVersion: 1\n");
    }
}