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.
//! Hand-rolled minimal yaml parser for action.yml — extracts only
//! description / inputs / outputs without pulling the full
//! serde_yaml dependency-graph cost. The actions repo's yaml is
//! shape-constrained (composite-action only), so we can use
//! line-based parsing safely.

use crate::InputSpec;
use std::collections::BTreeMap;

#[derive(Debug, Default)]
pub struct Parsed {
    pub description: String,
    pub inputs: BTreeMap<String, InputSpec>,
    pub outputs: BTreeMap<String, String>,
    /// `branding.icon` — both flow `branding: { icon: 'x', color: 'y' }`
    /// and block (icon on the next indented line) shapes parse here.
    pub branding_icon: Option<String>,
    /// `branding.color` — same as above.
    pub branding_color: Option<String>,
}

pub fn parse(src: &str) -> Parsed {
    let mut out = Parsed::default();
    let mut section: Option<&str> = None;
    let mut current_key: Option<String> = None;

    for raw in src.lines() {
        let line = raw.trim_end();

        // Top-level description: line
        if line.starts_with("description:") {
            out.description = unquote(line.splitn(2, ':').nth(1).unwrap_or("").trim());
            continue;
        }

        // Section headers
        if line.starts_with("inputs:") {
            section = Some("inputs");
            current_key = None;
            continue;
        }
        if line.starts_with("outputs:") {
            section = Some("outputs");
            current_key = None;
            continue;
        }
        if line.starts_with("runs:") || line.starts_with("name:") {
            section = None;
            current_key = None;
            continue;
        }
        // `branding:` opens either flow `{ icon: 'x', color: 'y' }` on
        // the same line, or block on indented lines below. We support
        // both by capturing inline first, then arming `section` so the
        // 2-space block parser below catches `  icon: …` / `  color: …`.
        if line.starts_with("branding:") {
            let after = line.splitn(2, ':').nth(1).unwrap_or("").trim();
            if after.starts_with('{') {
                // Flow form: `{ icon: 'x', color: 'y' }`
                let inner = after.trim_start_matches('{').trim_end_matches('}').trim();
                for pair in inner.split(',') {
                    let mut kv = pair.splitn(2, ':');
                    let k = kv.next().unwrap_or("").trim();
                    let v = kv.next().unwrap_or("").trim();
                    let v = unquote(v);
                    if k == "icon" { out.branding_icon = Some(v); }
                    else if k == "color" { out.branding_color = Some(v); }
                }
                section = None;
            } else {
                section = Some("branding");
            }
            current_key = None;
            continue;
        }

        // 2-space-indented `  key:` opens a new entry inside the current section.
        if let Some(sec) = section {
            if let Some(rest) = line.strip_prefix("  ") {
                if sec == "branding" {
                    // `  icon: 'tag'` / `  color: 'gray-dark'`
                    if let Some(v) = rest.strip_prefix("icon:") {
                        out.branding_icon = Some(unquote(v.trim()));
                    } else if let Some(v) = rest.strip_prefix("color:") {
                        out.branding_color = Some(unquote(v.trim()));
                    }
                    continue;
                }
                if !rest.starts_with(' ') && rest.ends_with(':') {
                    let key = rest.trim_end_matches(':').to_string();
                    current_key = Some(key.clone());
                    if sec == "inputs" {
                        out.inputs.insert(key, InputSpec::default());
                    } else {
                        out.outputs.insert(key, String::new());
                    }
                    continue;
                }
                // 4-space-indented attribute of the current entry
                if let Some(ck) = &current_key {
                    let attr = rest.trim();
                    if sec == "inputs" {
                        if let Some(spec) = out.inputs.get_mut(ck) {
                            if let Some(val) = attr.strip_prefix("required:") {
                                spec.required = val.trim().contains("true");
                            } else if let Some(val) = attr.strip_prefix("default:") {
                                spec.default = Some(unquote(val.trim()));
                            } else if let Some(val) = attr.strip_prefix("description:") {
                                spec.description = Some(unquote(val.trim()));
                            }
                        }
                    } else if sec == "outputs" {
                        if let Some(val) = attr.strip_prefix("description:") {
                            out.outputs.insert(ck.clone(), unquote(val.trim()));
                        }
                    }
                }
            }
        }
    }
    out
}

pub fn unquote(s: &str) -> String {
    let s = s.trim();
    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
        s[1..s.len() - 1].to_string()
    } else {
        s.to_string()
    }
}

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

    #[test]
    fn parses_flow_branding() {
        let src = "name: 'x'\nbranding: { icon: 'refresh-cw', color: 'blue' }\nruns:\n";
        let p = parse(src);
        assert_eq!(p.branding_icon.as_deref(), Some("refresh-cw"));
        assert_eq!(p.branding_color.as_deref(), Some("blue"));
    }

    #[test]
    fn parses_block_branding() {
        let src = "name: 'x'\nbranding:\n  icon: 'tag'\n  color: 'gray-dark'\nruns:\n";
        let p = parse(src);
        assert_eq!(p.branding_icon.as_deref(), Some("tag"));
        assert_eq!(p.branding_color.as_deref(), Some("gray-dark"));
    }

    #[test]
    fn parses_unquoted_block_branding() {
        let src = "name: x\nbranding:\n  icon: tag\n  color: gray-dark\nruns:\n";
        let p = parse(src);
        assert_eq!(p.branding_icon.as_deref(), Some("tag"));
        assert_eq!(p.branding_color.as_deref(), Some("gray-dark"));
    }

    #[test]
    fn absent_branding_yields_none() {
        let src = "name: 'x'\ndescription: 'd'\nruns:\n";
        let p = parse(src);
        assert!(p.branding_icon.is_none());
        assert!(p.branding_color.is_none());
    }
}