use super::Language;
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
use std::process::Command;
const RUST_PACKAGES: &[(&str, &str)] = &[
("codama", "^1.5.0"),
("@codama/nodes-from-anchor", "^1.3.8"),
("@codama/renderers-rust", "^1.2.9"),
];
const TS_PACKAGES: &[(&str, &str)] = &[
("codama", "^1.5.0"),
("@codama/nodes-from-anchor", "^1.3.8"),
("@codama/renderers-js", "^2.5.0"),
];
const RUST_SCRIPT: &str = r#"import { rootNodeFromAnchor } from '@codama/nodes-from-anchor';
import { createFromRoot } from 'codama';
import { renderVisitor as renderRustVisitor } from '@codama/renderers-rust';
import fs from 'fs';
const [, , idlPath, generatedDir, crateFolder] = process.argv;
const idl = JSON.parse(fs.readFileSync(idlPath, 'utf-8'));
// A native Codama extraction (pinoc's codama_native module) is already a
// RootNode; only shank-shim output needs the Anchor-shaped conversion.
const rootNode = idl.kind === 'rootNode' ? idl : rootNodeFromAnchor(idl);
const codama = createFromRoot(rootNode);
await codama.accept(
renderRustVisitor(generatedDir, {
crateFolder,
deleteFolderBeforeRendering: true,
formatCode: false,
}),
);
console.log('pinoc-codama: render complete');
"#;
const TS_SCRIPT: &str = r#"import { rootNodeFromAnchor } from '@codama/nodes-from-anchor';
import { createFromRoot } from 'codama';
import { renderVisitor as renderJsVisitor } from '@codama/renderers-js';
import fs from 'fs';
const [, , idlPath, packageFolder] = process.argv;
const idl = JSON.parse(fs.readFileSync(idlPath, 'utf-8'));
const rootNode = idl.kind === 'rootNode' ? idl : rootNodeFromAnchor(idl);
const codama = createFromRoot(rootNode);
await codama.accept(
renderJsVisitor(packageFolder, {
deleteFolderBeforeRendering: true,
}),
);
console.log('pinoc-codama: render complete');
"#;
fn package_json(packages: &[(&str, &str)]) -> String {
let deps = packages
.iter()
.map(|(name, range)| format!(" \"{name}\": \"{range}\""))
.collect::<Vec<_>>()
.join(",\n");
format!(
"{{\n \"name\": \"pinoc-codama-tooling\",\n \"private\": true,\n \"type\": \"module\",\n \"dependencies\": {{\n{deps}\n }}\n}}\n"
)
}
pub fn generate_via_codama(
idl_path: &Path,
out_dir: &Path,
auto_install: bool,
language: Language,
) -> Result<()> {
check_node_available()?;
let (packages, script) = match language {
Language::Rust => (RUST_PACKAGES, RUST_SCRIPT),
Language::Ts => (TS_PACKAGES, TS_SCRIPT),
};
let tooling_dir = out_dir.join(".pinoc-codama");
let node_modules = tooling_dir.join("node_modules");
let needs_install = packages
.iter()
.any(|(name, _)| !node_modules.join(name).exists());
if needs_install && !auto_install {
let specs = packages
.iter()
.map(|(name, range)| format!("'{name}@{range}'"))
.collect::<Vec<_>>()
.join(" ");
let flag = match language {
Language::Rust => "",
Language::Ts => " --language ts",
};
anyhow::bail!(
"codama's npm dependencies aren't installed yet.\n\n\
Install them with:\n npm install --prefix {} {specs}\n\n\
Or rerun with: pinoc client generate --generator codama{flag} --auto-install",
tooling_dir.display()
);
}
fs::create_dir_all(&tooling_dir)
.with_context(|| format!("Failed to create {}", tooling_dir.display()))?;
fs::write(tooling_dir.join("package.json"), package_json(packages))
.with_context(|| "Failed to write package.json")?;
ensure_gitignored(".pinoc-codama/")?;
let script_path = tooling_dir.join("convert_and_render.mjs");
fs::write(&script_path, script).with_context(|| "Failed to write conversion script")?;
if needs_install {
println!("📦 Installing codama (first run only)...");
let status = Command::new("npm")
.arg("install")
.current_dir(&tooling_dir)
.status()
.with_context(|| "Failed to run 'npm install'")?;
if !status.success() {
anyhow::bail!("'npm install' failed with exit code: {:?}", status.code());
}
}
let src_dir = out_dir.join("src");
fs::create_dir_all(&src_dir)?;
let idl_path_abs = fs::canonicalize(idl_path)
.with_context(|| format!("Failed to resolve {}", idl_path.display()))?;
let out_dir_abs = fs::canonicalize(out_dir)
.with_context(|| format!("Failed to resolve {}", out_dir.display()))?;
let mut render = Command::new("node");
render.arg(&script_path).arg(&idl_path_abs);
match language {
Language::Rust => {
render
.arg(out_dir_abs.join("src").join("generated"))
.arg(&out_dir_abs);
}
Language::Ts => {
render.arg(&out_dir_abs);
}
}
let status = render
.status()
.with_context(|| "Failed to run codama render script")?;
if !status.success() {
anyhow::bail!("codama render failed with exit code: {:?}", status.code());
}
match language {
Language::Rust => {
fs::write(out_dir.join("Cargo.toml"), cargo_toml())?;
fs::write(src_dir.join("lib.rs"), lib_rs(&src_dir.join("generated")))?;
}
Language::Ts => {
let index = src_dir.join("index.ts");
if !index.exists() {
fs::write(index, "export * from './generated';\n")?;
}
}
}
Ok(())
}
fn ensure_gitignored(pattern: &str) -> Result<()> {
let cwd = std::env::current_dir()?;
let gitignore_path = cwd.join(".gitignore");
let gitignore_exists = gitignore_path.exists();
if !gitignore_exists && !cwd.join(".git").exists() {
return Ok(());
}
let existing = fs::read_to_string(&gitignore_path).unwrap_or_default();
if existing
.lines()
.any(|line| line.trim().trim_end_matches('/') == pattern.trim_end_matches('/'))
{
return Ok(());
}
let mut updated = existing;
if !updated.is_empty() && !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str(pattern);
updated.push('\n');
fs::write(&gitignore_path, updated)
.with_context(|| format!("Failed to update {}", gitignore_path.display()))?;
println!("📝 Added {pattern} to .gitignore");
Ok(())
}
fn check_node_available() -> Result<()> {
let node_ok = Command::new("node")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let npm_ok = Command::new("npm")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if node_ok && npm_ok {
return Ok(());
}
anyhow::bail!(
"Node.js/npm not found, required for the codama client generator.\n\n\
Quick install:\n \
curl -fsSL https://fnm.vercel.app/install | bash # installs fnm (Node version manager)\n \
fnm install --lts && fnm use lts-latest\n\n\
Or download an installer directly: https://nodejs.org/en/download\n\n\
Once installed, rerun: pinoc client generate --generator codama"
);
}
fn lib_rs(generated_dir: &Path) -> String {
let mut reexports = String::new();
for module in ["accounts", "instructions", "types"] {
if generated_dir.join(module).join("mod.rs").exists() {
reexports.push_str(&format!("pub use generated::{module}::*;\n"));
}
}
format!(
"#![allow(warnings)]\n\
//! This code was generated by `pinoc client generate --generator codama`.\n\
//! Do not edit by hand; rerun the command instead.\n\
\n\
pub mod generated;\n\
pub use generated::*;\n\
pub use generated::errors::*;\n\
pub use generated::programs::*;\n\
{reexports}"
)
}
fn cargo_toml() -> String {
r#"[package]
name = "codama-client"
version = "0.1.0"
edition = "2021"
[dependencies]
borsh = { version = "1", features = ["derive"] }
solana-address = { version = "2.6", features = ["decode", "borsh"] }
solana-pubkey = "4.2"
solana-instruction = "3.4"
solana-account-info = "3"
solana-program-error = "3"
solana-cpi = "3"
thiserror = "2"
num-derive = "0.4"
num-traits = "0.2"
"#
.to_string()
}