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.
//! caixa-forge — mass scaffold engine.
//!
//! Takes a minimal `ScaffoldSpec` (name + ecosystem + a few optional
//! fields) and emits a complete `.caixa.lisp` source via the typed
//! `sexp_ast` AST. The downstream `caixa` subcommand renders the
//! .caixa.lisp into 30+ artifacts (Cargo.toml/package.json/.gemspec/
//! flake.nix/nix module trio/CI workflow shims/etc).
//!
//! Per the ★★★ prime directive — every emitted character of Lisp
//! source flows through `sexp_ast`, never `format!()`. The string
//! values inside `:k "v"` keyword args use sexp_ast::SExp::str()
//! which carries the typed-escape guarantee.
//!
//! The compounding leverage: one operator types
//!
//!     pleme-doc-gen scaffold --ecosystem rust-single-crate \
//!                            --name my-crate \
//!                            --description "Demo crate"
//!
//! and gets a full .caixa.lisp ready for `caixa --source ...` which
//! in turn renders the entire repo scaffold (typed Cargo.toml +
//! flake.nix + Nix module trio + 3 CI workflow shims + .pleme-io-
//! release.toml). On push to a fresh GitHub repo, the substrate
//! `auto-release.yml` workflow auto-bumps + publishes to crates.io.
//! Operator-side cost: ~3 lines of input. Artifact: a fully
//! GitHub-flow-ready public open-source crate.

use crate::sexp_ast::{Forms, SExp};
use std::collections::BTreeMap;

/// The minimal author-side input to scaffold a caixa source.
///
/// Required: name + ecosystem.
/// Everything else has typed defaults that produce a working caixa.
#[derive(Debug, Clone)]
pub struct ScaffoldSpec {
    pub name: String,
    pub ecosystem: String,
    pub kind: Option<String>,
    pub version: Option<String>,
    pub description: Option<String>,
    pub license: Option<String>,
    pub repository: Option<String>,
    pub homepage: Option<String>,
    pub authors: Option<String>,
    pub group_id: Option<String>,
    /// Extra `:package { … }` keys (group-id, authors, type, …) when
    /// the ecosystem needs more than the defaults provide.
    pub extra_package: BTreeMap<String, String>,
    /// Workflow shim names to emit under `:workflows [ … ]`.
    pub workflows: Vec<String>,
    /// `[package].keywords` — crates.io discoverability surface
    /// (max 5, each ≤20 chars per crates.io). Emitted at the top
    /// level of the .caixa.lisp as `:keywords [ … ]`.
    pub keywords: Vec<String>,
    /// `[package].categories` — crates.io taxonomy slugs.
    /// Emitted as top-level `:categories [ … ]`.
    pub categories: Vec<String>,
}

impl ScaffoldSpec {
    /// Minimum viable spec — caller fills only name + ecosystem; the
    /// rest takes per-ecosystem defaults.
    pub fn new(name: impl Into<String>, ecosystem: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            ecosystem: ecosystem.into(),
            kind: None,
            version: None,
            description: None,
            license: None,
            repository: None,
            homepage: None,
            authors: None,
            group_id: None,
            extra_package: BTreeMap::new(),
            workflows: Vec::new(),
            keywords: Vec::new(),
            categories: Vec::new(),
        }
    }
}

/// Per-ecosystem defaults the substrate hard-codes — sensible "just
/// works" values that the operator can override via ScaffoldSpec.
struct EcosystemDefaults {
    kind: &'static str,
    version: &'static str,
    license: &'static str,
    workflows: &'static [&'static str],
}

fn defaults_for(eco: &str) -> EcosystemDefaults {
    match eco {
        // Library defaults (Biblioteca = library)
        "rust-single-crate" | "rust-workspace" | "npm" | "python" |
        "go" | "java-maven" | "java-gradle-kts" | "dotnet-csproj" |
        "ocaml-dune" | "swift-spm" | "elixir-mix" | "ruby-gem" |
        "nim-nimble" | "scala-sbt" | "clojure-deps" | "r-description" |
        "lua-rockspec" | "cpp-conan" | "python-conda" | "python-pipenv" |
        "python-pdm" | "js-deno" | "js-pnpm" | "cpp-vcpkg" | "cpp-meson" |
        "cpp-cmake" | "fortran-fpm" | "gleam" | "ada-alire" | "haskell-cabal" |
        "racket-info" | "crystal" | "dart" | "composer" | "julia" => EcosystemDefaults {
            kind: "Biblioteca",
            version: "0.1.0",
            license: "MIT",
            workflows: &["auto-release", "pre-merge-gate", "security-gate"],
        },
        "zig" => EcosystemDefaults {
            kind: "Binario",
            version: "0.1.0",
            license: "MIT",
            workflows: &["auto-release", "pre-merge-gate"],
        },
        "helm" => EcosystemDefaults {
            kind: "Aplicacao",
            version: "0.1.0",
            license: "Apache-2.0",
            workflows: &["auto-release", "pre-merge-gate"],
        },
        "github-action" => EcosystemDefaults {
            kind: "Aplicacao",
            version: "0.1.0",
            license: "MIT",
            workflows: &["auto-release", "pre-merge-gate"],
        },
        _ => EcosystemDefaults {
            kind: "Biblioteca",
            version: "0.1.0",
            license: "MIT",
            workflows: &["auto-release", "pre-merge-gate", "security-gate"],
        },
    }
}

/// Default repository URL — operators publish under pleme-io by
/// default but can override via `--repository`.
fn default_repository(name: &str) -> String {
    let mut url = String::from("https://github.com/pleme-io/");
    url.push_str(name);
    url
}

/// Render the typed (defcaixa …) form for the given spec. Produces
/// a `sexp_ast::Forms` that the unified `ast::Render` trait emits.
pub fn build(spec: &ScaffoldSpec) -> Forms {
    let d = defaults_for(&spec.ecosystem);
    let kind = spec.kind.clone().unwrap_or_else(|| d.kind.to_string());
    let version = spec.version.clone().unwrap_or_else(|| d.version.to_string());
    let license = spec.license.clone().unwrap_or_else(|| d.license.to_string());
    let repo = spec.repository.clone()
        .unwrap_or_else(|| default_repository(&spec.name));

    // :package { :name "..." :version "..." :description "..." ... }
    let mut package_map: Vec<(SExp, SExp)> = vec![
        (SExp::kw("name"), SExp::str(&spec.name)),
        (SExp::kw("version"), SExp::str(version)),
    ];
    if let Some(desc) = &spec.description {
        package_map.push((SExp::kw("description"), SExp::str(desc)));
    }
    package_map.push((SExp::kw("license"), SExp::str(license)));
    package_map.push((SExp::kw("repository"), SExp::str(repo)));
    if let Some(home) = &spec.homepage {
        package_map.push((SExp::kw("homepage"), SExp::str(home)));
    }
    if let Some(authors) = &spec.authors {
        package_map.push((SExp::kw("authors"), SExp::str(authors)));
    }
    if let Some(gid) = &spec.group_id {
        package_map.push((SExp::kw("group-id"), SExp::str(gid)));
    }
    for (k, v) in &spec.extra_package {
        package_map.push((SExp::kw(k), SExp::str(v)));
    }
    // keywords + categories live INSIDE :package (matches the
    // caixa parser's read_vector_in_dict expectation; mirrors the
    // Cargo.toml `[package].keywords` shape).
    if !spec.keywords.is_empty() {
        package_map.push((SExp::kw("keywords"),
            SExp::Vector(spec.keywords.iter().map(SExp::str).collect())));
    }
    if !spec.categories.is_empty() {
        package_map.push((SExp::kw("categories"),
            SExp::Vector(spec.categories.iter().map(SExp::str).collect())));
    }

    // :workflows [ :auto-release :pre-merge-gate … ]
    let workflows_src: Vec<&str> = if spec.workflows.is_empty() {
        d.workflows.iter().copied().collect()
    } else {
        spec.workflows.iter().map(String::as_str).collect()
    };
    let workflow_kws: Vec<SExp> = workflows_src.iter().map(|w| SExp::kw(*w)).collect();

    // :ci-config { :bump { :default-type "patch" } :publish { :no-verify true } }
    // These defaults make the substrate auto-release.yml dispatcher
    // pick up the right bump strategy + publish settings on first push.
    // Operator can override per repo by editing the .caixa.lisp.
    let ci_config = SExp::Map(vec![
        (
            SExp::kw("bump"),
            SExp::Map(vec![(SExp::kw("default-type"), SExp::str("patch"))]),
        ),
        (
            SExp::kw("publish"),
            SExp::Map(vec![(SExp::kw("no-verify"), SExp::sym("true"))]),
        ),
    ]);

    // (defcaixa
    //   :name "..."
    //   :kind :Kind
    //   :ecosystem :ecosystem
    //   :package { … }
    //   :keywords [ … ]      ← emitted only when non-empty
    //   :categories [ … ]    ← emitted only when non-empty
    //   :ci-config { … }
    //   :workflows [ … ])
    let form = SExp::list([
        SExp::sym("defcaixa"),
        SExp::kw("name"), SExp::str(&spec.name),
        SExp::kw("kind"), SExp::kw(kind),
        SExp::kw("ecosystem"), SExp::kw(&spec.ecosystem),
        SExp::kw("package"), SExp::Map(package_map),
        SExp::kw("ci-config"), ci_config,
        SExp::kw("workflows"), SExp::Vector(workflow_kws),
    ]);

    Forms(vec![form])
}

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

    #[test]
    fn minimal_spec_renders_complete_caixa() {
        let spec = ScaffoldSpec::new("demo", "rust-single-crate");
        let out = build(&spec).render();
        assert!(out.contains("defcaixa"));
        assert!(out.contains("\"demo\""));
        // sexp_ast lists each form on its own line — keyword + value
        // may be on adjacent lines, semantically a single pair.
        assert!(out.contains(":kind") && out.contains(":Biblioteca"),
            "default kind for rust-single-crate should be Biblioteca; got: {out}");
        assert!(out.contains(":ecosystem") && out.contains(":rust-single-crate"));
        assert!(out.contains("\"MIT\""), "default license");
        assert!(out.contains("\"0.1.0\""), "default version");
        assert!(out.contains(":auto-release"), "default workflow");
    }

    #[test]
    fn description_threads_through_with_typed_escape() {
        let mut spec = ScaffoldSpec::new("escape-test", "rust-single-crate");
        spec.description = Some(r#"A "quoted" test"#.to_string());
        let out = build(&spec).render();
        // sexp_ast::str escapes the inner double-quote.
        assert!(out.contains(r#":description "A \"quoted\" test""#),
            "description should be typed-escaped; got: {out}");
    }

    #[test]
    fn helm_picks_aplicacao_kind_and_apache_default() {
        let spec = ScaffoldSpec::new("my-chart", "helm");
        let out = build(&spec).render();
        assert!(out.contains(":kind") && out.contains(":Aplicacao"));
        assert!(out.contains("\"Apache-2.0\""));
    }

    #[test]
    fn zig_defaults_to_binario_kind() {
        let spec = ScaffoldSpec::new("tool", "zig");
        let out = build(&spec).render();
        assert!(out.contains(":kind") && out.contains(":Binario"));
    }

    #[test]
    fn custom_workflows_override_defaults() {
        let mut spec = ScaffoldSpec::new("x", "rust-single-crate");
        spec.workflows = vec!["custom-flow".to_string()];
        let out = build(&spec).render();
        assert!(out.contains(":custom-flow"));
        assert!(!out.contains(":auto-release"), "should not include defaults");
    }

    #[test]
    fn round_trip_scaffold_then_render_emits_artifacts() {
        // End-to-end: scaffold → caixa::render → file count > 0.
        // This is the headline compounding test — the scaffold has
        // to produce a .caixa.lisp the M3 caixa pipeline accepts.
        let spec = ScaffoldSpec::new("scaffold-rt", "rust-single-crate");
        let src = build(&spec).render();
        let tmp = tempdir::TempDir::new("scaffold-rt")
            .expect("tempdir");
        let files = crate::caixa::render(&src, tmp.path(), true)
            .expect("caixa::render of scaffolded source");
        assert!(files.len() > 5,
            "scaffold→render should emit >5 files; got {}", files.len());
        // Cargo.toml should be among them.
        let names: Vec<String> = files.iter()
            .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
            .collect();
        assert!(names.iter().any(|n| n == "Cargo.toml"),
            "scaffolded rust-single-crate should produce Cargo.toml; got {names:?}");
    }
}