pleme-doc-gen 0.1.2

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.
//! pleme-doc-gen — typed Rust replacement for the M0 Python
//! _gen-patterns.py + _gen-docs.py scripts in pleme-io/actions.
//!
//! Per the ★★ NO-SHELL prime directive
//! (https://github.com/pleme-io/blackmatter-pleme/blob/main/skills/pleme-io-pattern-core/SKILL.md):
//! build-time generators belong in Rust, not Python.
//!
//! Subcommands:
//!   patterns   emit substrate/lib/release/patterns-full.nix from action.yml files
//!   docs       emit README.md per action from action.yml
//!   index      emit root README.md catalog index

#![warn(clippy::pedantic)]

use clap::{Parser, Subcommand};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;

mod caixa;
mod category;
mod docs;
mod patterns;
mod yaml;

#[derive(Parser)]
#[command(name = "pleme-doc-gen", version, about = "pleme-io actions docs + catalog generator")]
struct Cli {
    /// Path to the pleme-io/actions repo root (defaults to CWD)
    #[arg(long, default_value = ".")]
    actions_dir: PathBuf,

    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Emit substrate/lib/release/patterns-full.nix to stdout
    Patterns,
    /// Write per-action README.md files in-place
    Docs,
    /// Write root README.md catalog index
    Index,
    /// Write all three (docs + index + patterns)
    All {
        /// Where to write the patterns-full.nix output
        #[arg(long)]
        patterns_out: Option<PathBuf>,
    },
    /// Render a (defcaixa ...) source into the target repo's
    /// adoption surface (Cargo.toml + .pleme-io-release.toml +
    /// 3 .github/workflows shims). M3 proof — currently supports
    /// :ecosystem :rust-single-crate; other ecosystems land as
    /// follow-up commits per the canonical pattern.
    Caixa {
        /// Path to the .caixa.lisp source
        #[arg(long)]
        source: PathBuf,
        /// Where to render the artifacts (default: CWD)
        #[arg(long, default_value = ".")]
        out: PathBuf,
        /// Overwrite existing files (default: skip)
        #[arg(long)]
        force: bool,
    },
}

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    let actions = scan(&cli.actions_dir)?;
    match cli.cmd {
        Cmd::Patterns => {
            print!("{}", patterns::emit(&actions));
        }
        Cmd::Docs => {
            let written = docs::write_per_action(&cli.actions_dir, &actions)?;
            eprintln!("wrote {written} per-action READMEs");
        }
        Cmd::Index => {
            let path = cli.actions_dir.join("README.md");
            fs::write(&path, docs::emit_index(&actions))?;
            eprintln!("wrote {}", path.display());
        }
        Cmd::All { patterns_out } => {
            let docs_written = docs::write_per_action(&cli.actions_dir, &actions)?;
            eprintln!("wrote {docs_written} per-action READMEs");

            let index_path = cli.actions_dir.join("README.md");
            fs::write(&index_path, docs::emit_index(&actions))?;
            eprintln!("wrote {}", index_path.display());

            let patterns_text = patterns::emit(&actions);
            if let Some(out) = patterns_out {
                fs::write(&out, &patterns_text)?;
                eprintln!("wrote {}", out.display());
            } else {
                print!("{patterns_text}");
            }
        }
        Cmd::Caixa { source, out, force } => {
            let src = fs::read_to_string(&source)?;
            let written = caixa::render(&src, &out, force)?;
            eprintln!("rendered {} artifact(s) from {}:", written.len(), source.display());
            for f in &written {
                eprintln!("  {}", f.display());
            }
        }
    }
    Ok(())
}

#[derive(Debug, Clone)]
pub struct Action {
    pub name: String,
    pub description: String,
    pub inputs: BTreeMap<String, InputSpec>,
    pub outputs: BTreeMap<String, String>,
    pub category: String,
    pub backend: String,
}

#[derive(Debug, Clone, Default)]
pub struct InputSpec {
    pub required: bool,
    pub default: Option<String>,
    pub description: Option<String>,
}

fn scan(dir: &std::path::Path) -> anyhow::Result<Vec<Action>> {
    let mut out = vec![];
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let name = entry.file_name().into_string().unwrap_or_default();
        if name.starts_with('_') || name.starts_with('.') {
            continue;
        }
        let action_yml = entry.path().join("action.yml");
        if !action_yml.exists() {
            continue;
        }
        let yml = fs::read_to_string(&action_yml)?;
        let parsed = yaml::parse(&yml);
        let category = category::categorize(&name).into();
        let backend = if entry.path().join("run.tlisp").exists() {
            "tatara-lisp".into()
        } else {
            "shell".into()
        };
        out.push(Action {
            name,
            description: parsed.description,
            inputs: parsed.inputs,
            outputs: parsed.outputs,
            category,
            backend,
        });
    }
    out.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(out)
}