use anyhow::{bail, Result};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
pub fn render(src: &str, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let parsed = parse(src)?;
match parsed.ecosystem.as_str() {
"rust-single-crate" => render_rust_single(&parsed, out, force),
"rust-workspace" => render_rust_workspace(&parsed, out, force),
"npm" => render_npm(&parsed, out, force),
"python" => render_python(&parsed, out, force),
"helm" => render_helm(&parsed, out, force),
"github-action" => render_github_action(&parsed, out, force),
other => bail!(
"ecosystem '{other}' not supported. \
Supported: rust-single-crate, rust-workspace, npm, python, helm, github-action."
),
}
}
#[derive(Debug, Default)]
pub struct Caixa {
pub name: String,
pub kind: String,
pub ecosystem: String,
pub package: BTreeMap<String, String>,
pub package_lists: BTreeMap<String, Vec<String>>,
pub workflows: Vec<String>,
pub ci_config: BTreeMap<String, BTreeMap<String, String>>,
}
fn parse(src: &str) -> Result<Caixa> {
let cleaned: String = src
.lines()
.map(|l| match l.find(";;") {
Some(i) => &l[..i],
None => l,
})
.collect::<Vec<_>>()
.join("\n");
let cleaned = cleaned.trim();
if !cleaned.starts_with("(defcaixa") {
bail!("source does not start with (defcaixa ...)");
}
let after_keyword = &cleaned[("(defcaixa".len())..].trim_start();
let name_end = after_keyword
.find(|c: char| c.is_whitespace())
.unwrap_or(after_keyword.len());
let name = after_keyword[..name_end].to_string();
let body = &after_keyword[name_end..];
let mut out = Caixa {
name,
..Default::default()
};
out.kind = read_keyword(body, ":kind").unwrap_or_default();
out.ecosystem = read_keyword(body, ":ecosystem").unwrap_or_default();
if let Some(pkg_block) = read_dict_block(body, ":package") {
out.package = parse_dict(&pkg_block);
if let Some(cats) = read_vector_in_dict(&pkg_block, ":categories") {
out.package_lists.insert("categories".into(), cats);
}
if let Some(kws) = read_vector_in_dict(&pkg_block, ":keywords") {
out.package_lists.insert("keywords".into(), kws);
}
}
if let Some(wf) = read_vector(body, ":workflows") {
out.workflows = wf;
}
if let Some(ci_block) = read_dict_block(body, ":ci-config") {
for sub_key in ["bump", "publish", "security", "validation"] {
if let Some(sub_block) = read_dict_block(&ci_block, &format!(":{sub_key}")) {
out.ci_config.insert(sub_key.into(), parse_dict(&sub_block));
}
}
}
Ok(out)
}
fn find_after_keyword<'a>(s: &'a str, kw: &str) -> Option<&'a str> {
let mut start = 0;
while let Some(i) = s[start..].find(kw) {
let abs = start + i;
let after_kw = &s[abs + kw.len()..];
if after_kw.starts_with(|c: char| c.is_whitespace()) {
return Some(after_kw.trim_start());
}
start = abs + kw.len();
}
None
}
fn read_keyword(s: &str, kw: &str) -> Option<String> {
let after = find_after_keyword(s, kw)?;
let end = after
.find(|c: char| c.is_whitespace() || c == ')' || c == '}')
.unwrap_or(after.len());
Some(after[..end].trim_matches(':').to_string())
}
fn read_dict_block(s: &str, kw: &str) -> Option<String> {
let after = find_after_keyword(s, kw)?;
let after = after.trim_start();
if !after.starts_with('{') {
return None;
}
let mut depth = 0;
let mut end = 0;
for (j, c) in after.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = j + 1;
break;
}
}
_ => {}
}
}
if end == 0 {
return None;
}
Some(after[1..end - 1].to_string())
}
fn read_vector(s: &str, kw: &str) -> Option<Vec<String>> {
let after = find_after_keyword(s, kw)?;
if !after.starts_with('[') {
return None;
}
let mut depth = 0;
let mut end = 0;
for (j, c) in after.char_indices() {
match c {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
end = j + 1;
break;
}
}
_ => {}
}
}
if end == 0 {
return None;
}
let inner = &after[1..end - 1];
Some(
inner
.split_whitespace()
.map(|w| w.trim_matches('"').trim_start_matches(':').to_string())
.filter(|s| !s.is_empty())
.collect(),
)
}
fn read_vector_in_dict(dict: &str, kw: &str) -> Option<Vec<String>> {
read_vector(dict, kw)
}
fn parse_dict(inner: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut chars = inner.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c == ':' {
let mut end = i + 1;
while end < inner.len() && !inner.as_bytes()[end].is_ascii_whitespace() {
end += 1;
}
let kw = inner[i + 1..end].to_string();
let mut val_start = end;
while val_start < inner.len() && inner.as_bytes()[val_start].is_ascii_whitespace() {
val_start += 1;
}
if val_start >= inner.len() {
break;
}
if inner.as_bytes()[val_start] == b'{' || inner.as_bytes()[val_start] == b'[' {
let mut depth = 0;
let open = inner.as_bytes()[val_start] as char;
let close = if open == '{' { '}' } else { ']' };
let mut p = val_start;
for (j, c2) in inner[val_start..].char_indices() {
match c2 {
c if c == open => depth += 1,
c if c == close => {
depth -= 1;
if depth == 0 {
p = val_start + j + 1;
break;
}
}
_ => {}
}
}
while let Some(&(k, _)) = chars.peek() {
if k >= p {
break;
}
chars.next();
}
continue;
}
if inner.as_bytes()[val_start] == b'"' {
let mut end = val_start + 1;
while end < inner.len() && inner.as_bytes()[end] != b'"' {
end += 1;
}
out.insert(kw, inner[val_start + 1..end].to_string());
while let Some(&(k, _)) = chars.peek() {
if k > end {
break;
}
chars.next();
}
} else {
let mut end = val_start;
while end < inner.len() && !inner.as_bytes()[end].is_ascii_whitespace() {
end += 1;
}
out.insert(kw, inner[val_start..end].trim_start_matches(':').to_string());
while let Some(&(k, _)) = chars.peek() {
if k >= end {
break;
}
chars.next();
}
}
}
}
out
}
fn render_rust_single(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let wf_dir = out.join(".github/workflows");
fs::create_dir_all(&wf_dir)?;
let cargo_path = out.join("Cargo.toml");
if !cargo_path.exists() || force {
let pkg = &c.package;
let mut cargo = String::from("[package]\n");
let push_str = |s: &mut String, k: &str, v: &str| {
s.push_str(&format!("{k} = \"{v}\"\n"));
};
if let Some(n) = pkg.get("name") {
push_str(&mut cargo, "name", n);
}
if let Some(v) = pkg.get("version") {
push_str(&mut cargo, "version", v);
}
cargo.push_str("edition = \"2024\"\n");
if let Some(d) = pkg.get("description") {
push_str(&mut cargo, "description", d);
}
if let Some(l) = pkg.get("license") {
push_str(&mut cargo, "license", l);
}
if let Some(r) = pkg.get("repository") {
push_str(&mut cargo, "repository", r);
}
if let Some(cats) = c.package_lists.get("categories") {
let joined: Vec<String> = cats.iter().map(|s| format!("\"{s}\"")).collect();
cargo.push_str(&format!("categories = [{}]\n", joined.join(", ")));
}
if let Some(kws) = c.package_lists.get("keywords") {
let joined: Vec<String> = kws.iter().map(|s| format!("\"{s}\"")).collect();
cargo.push_str(&format!("keywords = [{}]\n", joined.join(", ")));
}
cargo.push_str("\n[dependencies]\n");
fs::write(&cargo_path, cargo)?;
written.push(cargo_path);
}
let cfg_path = out.join(".pleme-io-release.toml");
if !cfg_path.exists() || force {
let mut cfg = String::from("# Generated by pleme-doc-gen caixa\n");
for (section, fields) in &c.ci_config {
cfg.push_str(&format!("[{section}]\n"));
for (k, v) in fields {
let v = if matches!(v.as_str(), "true" | "false") {
v.clone()
} else {
format!("\"{v}\"")
};
cfg.push_str(&format!("{k} = {v}\n"));
}
cfg.push('\n');
}
fs::write(&cfg_path, cfg)?;
written.push(cfg_path);
}
for wf in &c.workflows {
let path = wf_dir.join(format!("{wf}.yml"));
if path.exists() && !force {
continue;
}
let content = match wf.as_str() {
"auto-release" => AUTO_RELEASE_SHIM,
"pre-merge-gate" => PRE_MERGE_GATE_SHIM,
"security-gate" => SECURITY_GATE_SHIM,
_ => continue,
};
fs::write(&path, content)?;
written.push(path);
}
Ok(written)
}
fn render_ci_shims(c: &Caixa, out: &Path, force: bool, written: &mut Vec<PathBuf>) -> Result<()> {
let wf_dir = out.join(".github/workflows");
fs::create_dir_all(&wf_dir)?;
let cfg_path = out.join(".pleme-io-release.toml");
if !cfg_path.exists() || force {
let mut cfg = String::from("# Generated by pleme-doc-gen caixa\n");
for (section, fields) in &c.ci_config {
cfg.push_str(&format!("[{section}]\n"));
for (k, v) in fields {
let v = if matches!(v.as_str(), "true" | "false") {
v.clone()
} else {
format!("\"{v}\"")
};
cfg.push_str(&format!("{k} = {v}\n"));
}
cfg.push('\n');
}
fs::write(&cfg_path, cfg)?;
written.push(cfg_path);
}
for wf in &c.workflows {
let path = wf_dir.join(format!("{wf}.yml"));
if path.exists() && !force {
continue;
}
let content = match wf.as_str() {
"auto-release" => AUTO_RELEASE_SHIM,
"pre-merge-gate" => PRE_MERGE_GATE_SHIM,
"security-gate" => SECURITY_GATE_SHIM,
_ => continue,
};
fs::write(&path, content)?;
written.push(path);
}
Ok(())
}
fn render_npm(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg_json = out.join("package.json");
if !pkg_json.exists() || force {
let pkg = &c.package;
let mut json = String::from("{\n");
let push = |s: &mut String, k: &str, v: &str, comma: bool| {
s.push_str(&format!(" \"{k}\": \"{v}\"{}\n", if comma { "," } else { "" }));
};
if let Some(n) = pkg.get("name") { push(&mut json, "name", n, true); }
if let Some(v) = pkg.get("version") { push(&mut json, "version", v, true); }
if let Some(d) = pkg.get("description") { push(&mut json, "description", d, true); }
if let Some(l) = pkg.get("license") { push(&mut json, "license", l, true); }
if let Some(r) = pkg.get("repository") {
json.push_str(&format!(" \"repository\": {{ \"type\": \"git\", \"url\": \"{r}\" }},\n"));
}
if let Some(kws) = c.package_lists.get("keywords") {
let joined: Vec<String> = kws.iter().map(|s| format!("\"{s}\"")).collect();
json.push_str(&format!(" \"keywords\": [{}],\n", joined.join(", ")));
}
json.push_str(" \"main\": \"index.js\",\n");
json.push_str(" \"scripts\": {\n \"test\": \"echo OK\"\n }\n");
json.push_str("}\n");
fs::write(&pkg_json, json)?;
written.push(pkg_json);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_python(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let pyproj = out.join("pyproject.toml");
if !pyproj.exists() || force {
let pkg = &c.package;
let mut toml = String::from("[project]\n");
if let Some(n) = pkg.get("name") { toml.push_str(&format!("name = \"{n}\"\n")); }
if let Some(v) = pkg.get("version") { toml.push_str(&format!("version = \"{v}\"\n")); }
if let Some(d) = pkg.get("description") { toml.push_str(&format!("description = \"{d}\"\n")); }
if let Some(l) = pkg.get("license") {
toml.push_str(&format!("license = {{ text = \"{l}\" }}\n"));
}
if let Some(rp) = pkg.get("requires-python") {
toml.push_str(&format!("requires-python = \"{rp}\"\n"));
}
toml.push_str("\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n");
fs::write(&pyproj, toml)?;
written.push(pyproj);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_helm(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let mut written = vec![];
fs::create_dir_all(out)?;
fs::create_dir_all(out.join("templates"))?;
let chart_yaml = out.join("Chart.yaml");
if !chart_yaml.exists() || force {
let pkg = &c.package;
let mut yml = String::from("apiVersion: v2\n");
if let Some(n) = pkg.get("name") { yml.push_str(&format!("name: {n}\n")); }
if let Some(d) = pkg.get("description") { yml.push_str(&format!("description: {d}\n")); }
yml.push_str(&format!("type: {}\n", pkg.get("type").map(String::as_str).unwrap_or("application")));
if let Some(v) = pkg.get("version") { yml.push_str(&format!("version: {v}\n")); }
if let Some(av) = pkg.get("appVersion") { yml.push_str(&format!("appVersion: \"{av}\"\n")); }
fs::write(&chart_yaml, yml)?;
written.push(chart_yaml);
}
let values = out.join("values.yaml");
if !values.exists() || force {
fs::write(&values, "# values.yaml — fill in per the chart's templates\n")?;
written.push(values);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_rust_workspace(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let cargo = out.join("Cargo.toml");
if !cargo.exists() || force {
let ws = &c.package;
let mut toml = String::from("[workspace]\n");
if let Some(members) = c.package_lists.get("members") {
let joined: Vec<String> = members.iter().map(|m| format!("\"{m}\"")).collect();
toml.push_str(&format!("members = [{}]\n", joined.join(", ")));
}
toml.push_str("resolver = \"2\"\n\n[workspace.package]\n");
if let Some(v) = ws.get("version") { toml.push_str(&format!("version = \"{v}\"\n")); }
toml.push_str("edition = \"2024\"\n");
if let Some(l) = ws.get("license") { toml.push_str(&format!("license = \"{l}\"\n")); }
if let Some(r) = ws.get("repository") { toml.push_str(&format!("repository = \"{r}\"\n")); }
fs::write(&cargo, toml)?;
written.push(cargo);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_github_action(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let action_yml = out.join("action.yml");
if !action_yml.exists() || force {
let pkg = &c.package;
let mut yml = format!("name: 'pleme-io · {}'\n", c.name);
if let Some(d) = pkg.get("description") { yml.push_str(&format!("description: '{d}'\n")); }
yml.push_str("branding: { icon: 'box', color: 'green' }\n\n");
yml.push_str("inputs: {}\noutputs: {}\n\n");
yml.push_str("runs:\n using: composite\n steps:\n");
yml.push_str(" - id: src\n shell: bash\n run: |\n");
yml.push_str(" {\n echo 'script<<TLISP_EOF'\n");
yml.push_str(" curl -sL https://raw.githubusercontent.com/pleme-io/actions/main/_tlisp-stdlib/stdlib.tlisp\n");
yml.push_str(" echo\n cat ${{ github.action_path }}/run.tlisp\n");
yml.push_str(" echo 'TLISP_EOF'\n } >> \"$GITHUB_OUTPUT\"\n");
yml.push_str(" - id: run\n uses: pleme-io/actions/tatara-script@v1\n");
yml.push_str(" with:\n script: ${{ steps.src.outputs.script }}\n");
fs::write(&action_yml, yml)?;
written.push(action_yml);
}
let run_tlisp = out.join("run.tlisp");
if !run_tlisp.exists() || force {
fs::write(&run_tlisp, ";; run.tlisp — rendered from .caixa.lisp\n(log-info \"::warning::stub body\")\n(exit 0)\n")?;
written.push(run_tlisp);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
const AUTO_RELEASE_SHIM: &str = r#"name: auto-release
# Generated from caixa source. Edit caixa, re-render.
on:
push:
branches: [main]
workflow_dispatch:
inputs:
bump-type:
description: "patch | minor | major"
default: patch
jobs:
release:
uses: pleme-io/substrate/.github/workflows/auto-release.yml@main
with:
bump-type: ${{ inputs.bump-type || 'patch' }}
secrets: inherit
"#;
const PRE_MERGE_GATE_SHIM: &str = r#"name: pre-merge-gate
# Generated from caixa source. Edit caixa, re-render.
on:
pull_request:
branches: [main]
jobs:
gate:
uses: pleme-io/substrate/.github/workflows/pre-merge-gate.yml@main
secrets: inherit
"#;
const SECURITY_GATE_SHIM: &str = r#"name: security-gate
# Generated from caixa source. Edit caixa, re-render.
on:
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1'
jobs:
gate:
uses: pleme-io/substrate/.github/workflows/security-gate.yml@main
secrets: inherit
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rust_library_caixa_renders() {
let src = r#"
(defcaixa my-lib
:kind :Biblioteca
:ecosystem :rust-single-crate
:package { :name "my-lib" :version "0.1.0" :description "A test lib." :license "MIT" }
:ci-config { :bump { :default-type "patch" :skip-when-no-source-changes true } }
:workflows [ :auto-release :pre-merge-gate :security-gate ])
"#;
let parsed = parse(src).unwrap();
assert_eq!(parsed.name, "my-lib");
assert_eq!(parsed.kind, "Biblioteca");
assert_eq!(parsed.ecosystem, "rust-single-crate");
assert_eq!(parsed.package.get("name").map(String::as_str), Some("my-lib"));
assert_eq!(parsed.package.get("version").map(String::as_str), Some("0.1.0"));
assert_eq!(parsed.workflows, vec!["auto-release", "pre-merge-gate", "security-gate"]);
assert!(parsed.ci_config.contains_key("bump"));
}
}