escriba-plugin 0.1.9

Plugin manifest + discovery for escriba — every plugin is a caixa with :kind Plugin. Phase 1 scaffold; full VM wiring in phase 2.
//! `escriba-plugin::forge` — emit a plugin caixa's published artifacts
//! from ONE typed catalog source.
//!
//! This is the generation half of the plugin substrate (Pillar 12 —
//! generation over composition). A catalog source
//! (`<name>.escribaplugin.lisp`) is one `(defescribaplugin …)` manifest
//! form followed by the plugin's escriba entry def-forms (see
//! [`escriba_lisp::catalog`]). [`forge_plugin`] reads the manifest and
//! produces a [`CaixaArtifacts`] bundle; [`write_plugin_caixa`]
//! materializes it into `<out>/<name>/…` as a complete, installable
//! caixa directory:
//!
//! ```text
//! <out>/<name>/
//! ├── <name>.escribaplugin.lisp   ← THE SPEC (persisted next to output)
//! ├── caixa.lisp                  ← generated :kind Biblioteca manifest
//! ├── escriba/plugin.lisp         ← the escriba entry (what escriba loads)
//! └── flake.nix                   ← minimal nix packaging
//! ```
//!
//! Persisting the spec into the output dir makes re-rendering idempotent
//! and drift-detectable via `git diff` (CLOSED-LOOP MASS-SYNTHESIS rule
//! #3): force a re-forge, diff, and a clean tree proves determinism.

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

use escriba_lisp::{CatalogError, EscribaPluginSpec, emit_caixa_lisp};
use thiserror::Error;

/// The four published artifacts of a plugin caixa, as strings.
#[derive(Debug, Clone)]
pub struct CaixaArtifacts {
    /// The `:kind Biblioteca` manifest (`caixa.lisp`).
    pub caixa_lisp: String,
    /// The escriba entry escriba loads + applies (`escriba/plugin.lisp`).
    pub entry_lisp: String,
    /// Minimal nix packaging (`flake.nix`).
    pub flake_nix: String,
    /// The catalog source verbatim — persisted alongside the output for
    /// round-trip auditability.
    pub spec_source: String,
}

#[derive(Debug, Error)]
pub enum ForgeError {
    #[error("catalog source: {0}")]
    Catalog(#[from] CatalogError),
    #[error("io error writing {path}: {source}")]
    Io {
        path: String,
        source: std::io::Error,
    },
}

/// Forge a plugin caixa's artifacts from one catalog source string.
///
/// The entry is the WHOLE source (the `defescribaplugin` manifest form
/// is inert at apply time — `escriba_lisp::apply_source` ignores it), so
/// nothing is lost and the source stays the single home for both the
/// manifest and the entry def-forms.
pub fn forge_plugin(source: &str) -> Result<(EscribaPluginSpec, CaixaArtifacts), ForgeError> {
    let spec = escriba_lisp::read_catalog_meta(source)?;
    let artifacts = CaixaArtifacts {
        caixa_lisp: emit_caixa_lisp(&spec),
        entry_lisp: emit_entry_lisp(&spec, source),
        flake_nix: emit_flake_nix(&spec),
        spec_source: source.to_string(),
    };
    Ok((spec, artifacts))
}

/// Render the escriba entry — a small generated header plus the catalog
/// source. The `defescribaplugin` manifest form remains (inert at apply
/// time) so the entry stays a faithful, re-forgeable projection of the
/// source rather than a lossy strip.
fn emit_entry_lisp(spec: &EscribaPluginSpec, source: &str) -> String {
    let mut out = String::new();
    out.push_str(";; GENERATED escriba entry — escriba LOADS + APPLIES this file.\n");
    out.push_str(&format!(
        ";; plugin: {} (v{})\n",
        spec.name,
        spec.effective_version()
    ));
    out.push_str(";; The (defescribaplugin …) manifest below is inert at apply time\n");
    out.push_str(";; (escriba-lisp ignores it); it is kept so the entry round-trips.\n");
    // Ensure the source ends with a newline so the header comment and
    // the first form are on separate lines.
    out.push_str(source.trim_start());
    if !out.ends_with('\n') {
        out.push('\n');
    }
    out
}

/// Render a minimal, real `flake.nix` for a standalone plugin caixa: a
/// pure-source package that copies the caixa tree into `$out` so it can
/// be fetched + materialized into escriba's plugins dir. Follows the
/// pleme-io flake-input rule (`inputs.nixpkgs.follows`-friendly); in the
/// fleet, plugin caixas are consumed as `flake = false` source by the
/// escribamourne distribution, so these per-plugin pins never compound
/// into the closure.
#[must_use]
pub fn emit_flake_nix(spec: &EscribaPluginSpec) -> String {
    let name = &spec.name;
    let desc = spec.description.replace('"', "'");
    format!(
        r#"# GENERATED by `escriba plugin forge` from {name}.escribaplugin.lisp.
# A pure-source escriba plugin caixa (:kind Biblioteca). Copied verbatim
# into escriba's plugins dir; escriba loads escriba/plugin.lisp.
{{
  description = "escriba plugin caixa: {name}{desc}";

  inputs = {{
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  }};

  outputs = {{ self, nixpkgs, flake-utils }}:
    flake-utils.lib.eachDefaultSystem (system:
      let pkgs = import nixpkgs {{ inherit system; }};
      in {{
        # The caixa, materialized: caixa.lisp + escriba/plugin.lisp.
        packages.default = pkgs.runCommandLocal "{name}" {{ }} ''
          mkdir -p "$out"
          cp -r ${{self}}/caixa.lisp "$out/" 2>/dev/null || true
          cp -r ${{self}}/escriba "$out/" 2>/dev/null || true
        '';
      }});
}}
"#
    )
}

/// Materialize a forged plugin caixa into `<out>/<name>/…`. Creates the
/// directory tree and writes all four artifacts. Overwrites existing
/// files (so a re-forge is idempotent).
pub fn write_plugin_caixa(
    spec: &EscribaPluginSpec,
    artifacts: &CaixaArtifacts,
    out: &Path,
) -> Result<PathBuf, ForgeError> {
    let root = out.join(&spec.name);
    let escriba_dir = root.join("escriba");
    mkdirs(&escriba_dir)?;

    write_file(&root.join("caixa.lisp"), &artifacts.caixa_lisp)?;
    write_file(&escriba_dir.join("plugin.lisp"), &artifacts.entry_lisp)?;
    write_file(&root.join("flake.nix"), &artifacts.flake_nix)?;
    write_file(
        &root.join(format!("{}.escribaplugin.lisp", spec.name)),
        &artifacts.spec_source,
    )?;
    Ok(root)
}

fn mkdirs(p: &Path) -> Result<(), ForgeError> {
    std::fs::create_dir_all(p).map_err(|e| ForgeError::Io {
        path: p.display().to_string(),
        source: e,
    })
}

fn write_file(p: &Path, contents: &str) -> Result<(), ForgeError> {
    std::fs::write(p, contents).map_err(|e| ForgeError::Io {
        path: p.display().to_string(),
        source: e,
    })
}

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

    const SRC: &str = r##"
        (defescribaplugin
          :name "escriba-gitsigns"
          :version "0.1.0"
          :category "git"
          :description "Git gutter signs, blame, hunks"
          :blnvim-origin "lewis6991/gitsigns.nvim"
          :ativar-em ("Event: BufReadPost"))

        (defkeybind :mode "normal" :key "<leader>gb" :action "git.blame"
                    :description "git blame")
        (defcmd :name "GitBlame" :description "toggle git blame" :action "git.blame")
        (defhighlight :group "GitSignsAdd" :fg "#a9bb8c")
    "##;

    #[test]
    fn forge_produces_reparseable_artifacts() {
        let (spec, art) = forge_plugin(SRC).expect("forge succeeds");
        assert_eq!(spec.name, "escriba-gitsigns");

        // caixa.lisp re-parses + carries kind + provenance.
        let caixa_forms = tatara_lisp::read(&art.caixa_lisp).expect("caixa.lisp re-parses");
        assert_eq!(caixa_forms.len(), 1);
        assert!(art.caixa_lisp.contains("Biblioteca"));
        assert!(art.caixa_lisp.contains("lewis6991/gitsigns.nvim"));

        // entry applies to an ApplyPlan with the expected def-forms.
        let plan = escriba_lisp::apply_source(&art.entry_lisp).expect("entry applies");
        assert_eq!(plan.keybinds.len(), 1);
        assert_eq!(plan.commands.len(), 1);
        assert_eq!(plan.highlights.len(), 1);

        // flake.nix mentions the plugin name + is non-empty.
        assert!(art.flake_nix.contains("escriba-gitsigns"));
    }

    #[test]
    fn write_materializes_full_caixa_tree() {
        let (spec, art) = forge_plugin(SRC).unwrap();
        let out = std::env::temp_dir().join("escriba-forge-test-out");
        let _ = std::fs::remove_dir_all(&out);
        let root = write_plugin_caixa(&spec, &art, &out).expect("write succeeds");

        assert!(root.join("caixa.lisp").exists());
        assert!(root.join("escriba/plugin.lisp").exists());
        assert!(root.join("flake.nix").exists());
        assert!(root.join("escriba-gitsigns.escribaplugin.lisp").exists());

        // The materialized caixa loads through the real PluginCaixa loader.
        let loaded = crate::PluginCaixa::load("escriba-gitsigns", "0.1.0", &[], &root)
            .expect("forged caixa loads via PluginCaixa");
        assert!(loaded.entry_src.contains("git.blame"));

        let _ = std::fs::remove_dir_all(&out);
    }

    #[test]
    fn re_forge_is_idempotent() {
        // Determinism: forging the same source twice yields byte-identical
        // artifacts (CLOSED-LOOP MASS-SYNTHESIS — drift-detectable).
        let (_, a) = forge_plugin(SRC).unwrap();
        let (_, b) = forge_plugin(SRC).unwrap();
        assert_eq!(a.caixa_lisp, b.caixa_lisp);
        assert_eq!(a.entry_lisp, b.entry_lisp);
        assert_eq!(a.flake_nix, b.flake_nix);
    }
}