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.
//! instantiate — reliable one-shot consumer cycle.
//!
//! Composes the substrate's existing primitives into the canonical
//! consumer-side flow: render → resolve deps → re-render (so auto-
//! wire of Cargo path deps fires) → optionally verify-tests.
//!
//! Per operator: refine for reliability. Operators no longer have to
//! remember the render→resolve→render-force→build dance. One verb
//! takes a consumer .caixa.lisp + output dir and produces a working
//! scaffold with all deps materialized + auto-wired, idempotent
//! across re-runs.

use anyhow::Result;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
pub struct InstantiateReport {
    pub source: PathBuf,
    pub out: PathBuf,
    pub initial_rendered: usize,
    pub deps_resolved: usize,
    pub deps_failed: usize,
    pub final_rendered: usize,
    pub verify_passed: Option<bool>,
    pub final_status: String,
}

impl InstantiateReport {
    pub fn is_success(&self) -> bool {
        self.deps_failed == 0
            && self.verify_passed.unwrap_or(true)
            && self.final_status.starts_with("instantiated")
    }
    pub fn to_json(&self) -> String {
        use crate::json_ast::Value;
        let mut o = Value::obj();
        o.insert("source", Value::s(self.source.to_string_lossy().to_string()));
        o.insert("out", Value::s(self.out.to_string_lossy().to_string()));
        o.insert("initial-rendered", Value::i(self.initial_rendered as i64));
        o.insert("deps-resolved", Value::i(self.deps_resolved as i64));
        o.insert("deps-failed", Value::i(self.deps_failed as i64));
        o.insert("final-rendered", Value::i(self.final_rendered as i64));
        if let Some(v) = self.verify_passed { o.insert("verify-passed", Value::b(v)); }
        o.insert("final-status", Value::s(&self.final_status));
        o.insert("success", Value::b(self.is_success()));
        crate::json_ast::render(&o)
    }
}

#[derive(Debug, Clone)]
pub struct InstantiateConfig {
    /// Run verify-tests after re-render. Default true.
    pub verify: bool,
    /// Skip caixa-deps-resolve (use when deps already materialized).
    pub skip_resolve: bool,
    /// Force re-clone in caixa-deps-resolve (rare; defaults to false).
    pub force_resolve: bool,
}

impl Default for InstantiateConfig {
    fn default() -> Self { Self { verify: true, skip_resolve: false, force_resolve: false } }
}

/// Run the consumer instantiation cycle.
pub fn instantiate(source_lisp: &Path, out: &Path, cfg: &InstantiateConfig) -> Result<InstantiateReport> {
    let mut report = InstantiateReport {
        source: source_lisp.to_path_buf(),
        out: out.to_path_buf(),
        initial_rendered: 0,
        deps_resolved: 0,
        deps_failed: 0,
        final_rendered: 0,
        verify_passed: None,
        final_status: "starting".to_string(),
    };

    // Stage 1 — initial render (creates .caixa-deps/MANIFEST.txt).
    let lisp_src = std::fs::read_to_string(source_lisp)?;
    std::fs::create_dir_all(out)?;
    let initial = crate::caixa::render(&lisp_src, out, true)?;
    report.initial_rendered = initial.len();
    report.final_status = "rendered-initial".to_string();

    // Stage 2 — resolve deps (idempotent; skips if no manifest).
    if !cfg.skip_resolve {
        let resolve_cfg = crate::caixa_deps::ResolveConfig {
            force: cfg.force_resolve,
            transitive: true,
            max_depth: 5,
        };
        let resolved = crate::caixa_deps::resolve(out, &resolve_cfg)?;
        report.deps_resolved = resolved.success_count;
        report.deps_failed = resolved.failure_count;
        if report.deps_failed > 0 {
            report.final_status = "deps-resolve-failed".to_string();
            return Ok(report);
        }
        report.final_status = "deps-resolved".to_string();
    }

    // Stage 3 — re-render with force=true. write_if_needed's typed
    // idempotence preserves operator-edited source (no "Replace this
    // stub" marker) while re-emitting Cargo.toml (now auto-wires
    // path deps because .caixa-deps/<name>/working/ exists) + any
    // substrate-owned files that need updates.
    let final_rendered = crate::caixa::render(&lisp_src, out, true)?;
    report.final_rendered = final_rendered.len();
    report.final_status = "re-rendered".to_string();

    // Stage 4 — verify-tests (optional). Uses test_command_for from
    // main; we inline-dispatch here to avoid a cross-module import.
    if cfg.verify {
        if let Some(eco) = detect_ecosystem_from_lisp(&lisp_src) {
            if let Some(cmd) = test_command_for(&eco) {
                let status = std::process::Command::new(&cmd[0])
                    .args(&cmd[1..])
                    .current_dir(out)
                    .status();
                match status {
                    Ok(s) if s.success() => {
                        report.verify_passed = Some(true);
                        report.final_status = "instantiated-verified".to_string();
                    }
                    Ok(_) => {
                        report.verify_passed = Some(false);
                        report.final_status = "instantiated-verify-failed".to_string();
                    }
                    Err(e) => {
                        report.verify_passed = Some(false);
                        report.final_status = format!("instantiated-verify-error: {e}");
                    }
                }
            } else {
                report.final_status = "instantiated-no-test-runner".to_string();
            }
        } else {
            report.final_status = "instantiated-ecosystem-unknown".to_string();
        }
    } else {
        report.final_status = "instantiated".to_string();
    }

    Ok(report)
}

/// Light-weight ecosystem detector — parses the :ecosystem keyword
/// from the lisp source without invoking the full caixa parser.
fn detect_ecosystem_from_lisp(src: &str) -> Option<String> {
    for (i, _) in src.match_indices(":ecosystem") {
        let after = src[i + ":ecosystem".len()..].trim_start();
        let after = after.strip_prefix(':')?;
        let end = after.find(|c: char| c.is_whitespace() || c == ')' || c == '}')
            .unwrap_or(after.len());
        return Some(after[..end].to_string());
    }
    None
}

/// Mirrors main.rs's test_command_for() — kept local so instantiate
/// can dispatch tests without a circular dep on main.
fn test_command_for(eco: &str) -> Option<Vec<String>> {
    let argv: &[&str] = match eco {
        "rust-single-crate" | "rust-workspace" => &["cargo", "build", "--release", "--quiet"],
        "npm" | "js-pnpm"                       => &["npm", "test"],
        "python" | "python-pdm" | "python-pipenv" => &["python", "-m", "pytest", "-q"],
        "go"                                    => &["go", "build", "./..."],
        "nix-flake"                             => &["nix", "flake", "check", "--no-build", "--accept-flake-config"],
        _ => return None,
    };
    Some(argv.iter().map(|s| s.to_string()).collect())
}

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

    #[test]
    fn detect_ecosystem_from_lisp_finds_keyword() {
        let src = "(defcaixa :name \"x\" :ecosystem :rust-single-crate :package {})";
        assert_eq!(detect_ecosystem_from_lisp(src), Some("rust-single-crate".to_string()));
    }

    #[test]
    fn detect_ecosystem_handles_multiline_lisp() {
        let src = "(defcaixa\n  :name \"x\"\n  :ecosystem :nix-flake\n  :package {})";
        assert_eq!(detect_ecosystem_from_lisp(src), Some("nix-flake".to_string()));
    }
}