pleme-doc-gen 0.1.45

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.
//! manifest_io — shared typed parsers for manifest reading.
//!
//! Per ★★ PRIME DIRECTIVE (duplication is a bug): consolidates the
//! single-purpose manifest parsers that previously appeared in both
//! `reverse.rs` (extraction side) and `fidelity.rs` (verification
//! side). Each parser is small, dependency-free (no serde / no toml
//! crate where avoidable), and TYPED at the boundary.
//!
//! Future ecosystem extractors + verifiers add one helper here and
//! re-use it from both sides; cuts the second-use cost to zero.

/// Read a top-level `<key> = "<value>"` from a TOML section. The
/// section header is matched literally (`[package]`, `[workspace.package]`,
/// `[project]`, etc). Strips quotes + comments; honors no escapes
/// (sufficient for typical manifest fields like name/version/license).
pub fn read_toml_string(text: &str, header: &str, key: &str) -> Option<String> {
    let mut in_section = false;
    for line in text.lines() {
        let t = line.trim();
        if t.starts_with('[') { in_section = t == header; continue; }
        if !in_section { continue; }
        if let Some(after) = t.strip_prefix(key) {
            let after = after.trim_start().strip_prefix('=')?.trim_start();
            let val = after.trim_matches('"').trim_matches('\'');
            let val = match val.find('#') { Some(i) => &val[..i], None => val };
            return Some(val.trim().trim_matches('"').to_string());
        }
    }
    None
}

/// True when a TOML string value is actually a workspace-inherit
/// inline table — `{ workspace = true }`. Use to redirect the lookup
/// from `[package]` to `[workspace.package]` for the real value.
pub fn is_workspace_inherit(v: &str) -> bool {
    let t = v.trim();
    t.contains("workspace") && t.contains("true") && t.contains('{')
}

/// True when a TOML section contains the shorthand
/// `<key>.workspace = true` directly (no inline table). Same intent
/// as `is_workspace_inherit` — flag inheritance, not a literal value.
pub fn has_workspace_shorthand(text: &str, header: &str, key: &str) -> bool {
    let mut in_section = false;
    let needle = {
        let mut s = String::from(key);
        s.push_str(".workspace");
        s
    };
    for line in text.lines() {
        let t = line.trim();
        if t.starts_with('[') { in_section = t == header; continue; }
        if !in_section { continue; }
        if t.starts_with(&needle) { return true; }
    }
    false
}

/// Cargo-aware string-field resolution. Honors workspace inheritance
/// in both forms: inline-table `{ workspace = true }` and shorthand
/// `<key>.workspace = true`. Tries [package] first, falls back to
/// [workspace.package] when the value is inheritance-marked.
pub fn read_cargo_field(text: &str, key: &str) -> Option<String> {
    if let Some(v) = read_toml_string(text, "[package]", key) {
        if is_workspace_inherit(&v) {
            return read_toml_string(text, "[workspace.package]", key);
        }
        return Some(v);
    }
    if has_workspace_shorthand(text, "[package]", key) {
        return read_toml_string(text, "[workspace.package]", key);
    }
    read_toml_string(text, "[workspace.package]", key)
}

/// Read a single-line TOML string array under a section header. Used
/// for `keywords`, `categories`, etc. Returns empty vec if absent or
/// multi-line (sufficient for typical Cargo / pyproject usage).
pub fn read_toml_string_array(text: &str, header: &str, key: &str) -> Vec<String> {
    let mut in_section = false;
    for line in text.lines() {
        let t = line.trim();
        if t.starts_with('[') { in_section = t == header; continue; }
        if !in_section { continue; }
        if let Some(after) = t.strip_prefix(key) {
            let after = after.trim_start();
            if !after.starts_with('=') { continue; }
            let after = after[1..].trim_start();
            if !after.starts_with('[') { continue; }
            let close = after.find(']').unwrap_or(after.len());
            let inner = &after[1..close];
            return inner.split(',')
                .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
                .filter(|s| !s.is_empty())
                .collect();
        }
    }
    Vec::new()
}

/// Read a top-level JSON string field via simple needle scan. Sufficient
/// for `"name": "..."` / `"description": "..."` shapes. Use serde_json
/// when array / nested-object semantics are needed.
pub fn read_json_string(text: &str, key: &str) -> Option<String> {
    let needle = {
        let mut s = String::from('"');
        s.push_str(key);
        s.push_str("\":");
        s
    };
    let pos = text.find(&needle)?;
    let after = text[pos + needle.len()..].trim_start();
    let after = after.strip_prefix('"')?;
    let end = after.find('"')?;
    Some(after[..end].to_string())
}

/// Read a top-level YAML scalar (key: "value" / 'value' / bare). Honors
/// both quote forms + their respective escape grammars.
pub fn read_yaml_string(text: &str, key: &str) -> Option<String> {
    let needle = {
        let mut s = String::from(key);
        s.push(':');
        s
    };
    for line in text.lines() {
        if let Some(rest) = line.trim_start().strip_prefix(&needle) {
            let val = rest.trim();
            if let Some(inner) = val.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
                let mut out = String::with_capacity(inner.len());
                let mut it = inner.chars().peekable();
                while let Some(c) = it.next() {
                    if c == '\\' {
                        if let Some(&n) = it.peek() {
                            if n == '"' || n == '\\' { out.push(it.next().unwrap()); continue; }
                            if n == 'n' { it.next(); out.push('\n'); continue; }
                            if n == 't' { it.next(); out.push('\t'); continue; }
                        }
                    }
                    out.push(c);
                }
                return Some(out);
            }
            if let Some(inner) = val.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
                return Some(inner.replace("''", "'"));
            }
            return Some(val.to_string());
        }
    }
    None
}

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

    #[test]
    fn toml_string_handles_package_header() {
        let t = "[package]\nname = \"foo\"\nversion = \"1.0\"\n";
        assert_eq!(read_toml_string(t, "[package]", "name").as_deref(), Some("foo"));
        assert_eq!(read_toml_string(t, "[package]", "version").as_deref(), Some("1.0"));
    }

    #[test]
    fn toml_string_array_parses_keywords() {
        let t = "[package]\nkeywords = [\"cli\", \"tool\", \"build\"]\n";
        let v = read_toml_string_array(t, "[package]", "keywords");
        assert_eq!(v, vec!["cli", "tool", "build"]);
    }

    #[test]
    fn toml_string_array_empty_on_missing() {
        let t = "[package]\nname = \"foo\"\n";
        assert!(read_toml_string_array(t, "[package]", "keywords").is_empty());
    }

    #[test]
    fn json_string_top_field() {
        let t = "{\n  \"name\": \"foo\",\n  \"version\": \"1.0\"\n}\n";
        assert_eq!(read_json_string(t, "name").as_deref(), Some("foo"));
    }

    #[test]
    fn yaml_string_handles_both_quote_forms() {
        let t = "name: 'foo'\ndescription: \"bar baz\"\n";
        assert_eq!(read_yaml_string(t, "name").as_deref(), Some("foo"));
        assert_eq!(read_yaml_string(t, "description").as_deref(), Some("bar baz"));
    }

    #[test]
    fn yaml_string_unescapes_double_quote_form() {
        let t = "description: \"buildkit's \\\"cache\\\" tool\"\n";
        assert_eq!(read_yaml_string(t, "description").as_deref(),
            Some("buildkit's \"cache\" tool"));
    }

    #[test]
    fn yaml_string_unescapes_single_quote_form() {
        let t = "description: 'buildkit''s cache'\n";
        assert_eq!(read_yaml_string(t, "description").as_deref(),
            Some("buildkit's cache"));
    }
}