use anyhow::{anyhow, bail, Context, Result};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct OssConvSpec {
pub conversion: String, pub upstream: String, pub wrapper: String, pub expect_ecosystem: String, pub description: Option<String>,
pub license: Option<String>,
pub version: Option<String>,
pub publish_org: String, pub publish_mode: PublishMode,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PublishMode {
DryRun,
Public,
Private,
}
impl PublishMode {
fn parse(kw: &str) -> Result<Self> {
match kw {
"dry-run" | ":dry-run" => Ok(Self::DryRun),
"public" | ":public" => Ok(Self::Public),
"private" | ":private" => Ok(Self::Private),
other => bail!("unknown :publish-mode {other:?} — expected :dry-run|:public|:private"),
}
}
fn as_str(&self) -> &'static str {
match self { Self::DryRun => "dry-run", Self::Public => "public", Self::Private => "private" }
}
}
pub fn parse_str(src: &str) -> Result<OssConvSpec> {
let cleaned: String = src.lines()
.map(|l| match l.find(';') {
Some(i) => &l[..i],
None => l,
})
.collect::<Vec<_>>()
.join("\n");
let trimmed = cleaned.trim();
let after_paren = trimmed.strip_prefix('(')
.ok_or_else(|| anyhow!("expected '(' at start of form"))?;
let mut tokens = after_paren.split_whitespace();
let head = tokens.next().ok_or_else(|| anyhow!("empty form"))?;
if head != "defossconv" {
bail!("expected (defossconv …) form, got ({head} …)");
}
let conversion = tokens.next()
.ok_or_else(|| anyhow!("missing conversion name after defossconv"))?
.to_string();
let mut spec = OssConvSpec {
conversion,
upstream: String::new(),
wrapper: String::new(),
expect_ecosystem: String::new(),
description: None,
license: None,
version: None,
publish_org: "pleme-io".to_string(),
publish_mode: PublishMode::DryRun,
};
let mut pairs = pair_iter(&cleaned);
while let Some((slot, value)) = pairs.next() {
match slot.as_str() {
":upstream" => spec.upstream = unquote(&value),
":wrapper" => spec.wrapper = unquote(&value),
":expect-ecosystem" => spec.expect_ecosystem = strip_colon(&value),
":description" => spec.description = Some(unquote(&value)),
":license" => spec.license = Some(unquote(&value)),
":version" => spec.version = Some(unquote(&value)),
":publish-org" => spec.publish_org = unquote(&value),
":publish-mode" => spec.publish_mode = PublishMode::parse(&value)?,
":rationale" | ":upstream-path" | ":emit-receipt" => {
}
_ => {} }
}
if spec.upstream.is_empty() { bail!("missing :upstream"); }
if spec.wrapper.is_empty() { bail!("missing :wrapper"); }
if spec.expect_ecosystem.is_empty() { bail!("missing :expect-ecosystem"); }
Ok(spec)
}
fn pair_iter(body: &str) -> SlotIter<'_> {
SlotIter { body, pos: body.find(':').unwrap_or(body.len()) }
}
struct SlotIter<'a> {
body: &'a str,
pos: usize,
}
impl<'a> Iterator for SlotIter<'a> {
type Item = (String, String);
fn next(&mut self) -> Option<Self::Item> {
let rest = self.body.get(self.pos..)?;
let kw_start = rest.find(':')?;
let abs_start = self.pos + kw_start;
let after_colon = &self.body[abs_start..];
let kw_end = after_colon[1..].find(|c: char| c.is_whitespace() || c == ')')
.map(|n| n + 1).unwrap_or(after_colon.len());
let kw = &after_colon[..kw_end];
let mut cursor = abs_start + kw_end;
let body_bytes = self.body.as_bytes();
while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
let (val, after_val) = if body_bytes.get(cursor) == Some(&b'"') {
let val_start = cursor + 1;
let mut i = val_start;
while i < body_bytes.len() {
if body_bytes[i] == b'\\' { i += 2; continue; }
if body_bytes[i] == b'"' { break; }
i += 1;
}
let val = &self.body[val_start..i];
(val.to_string(), i + 1)
} else {
let val_start = cursor;
let mut i = val_start;
while i < body_bytes.len() {
let c = body_bytes[i];
if c.is_ascii_whitespace() || c == b')' { break; }
i += 1;
}
(self.body[val_start..i].to_string(), i)
};
self.pos = after_val;
Some((kw.to_string(), val))
}
}
fn unquote(s: &str) -> String {
s.trim_matches('"').to_string()
}
fn strip_colon(s: &str) -> String {
s.trim_start_matches(':').to_string()
}
#[derive(Debug, Clone)]
pub struct Attestation {
pub conversion: String,
pub upstream: String,
pub wrapper: String,
pub ecosystem: String,
pub mode: PublishMode,
pub gate_a: bool,
pub gate_b: bool,
pub error: Option<String>,
pub forged_path: Option<String>,
}
impl Attestation {
pub fn to_json(&self) -> String {
use crate::json_ast::Value;
let mut root = Value::obj();
root.insert("conversion", Value::s(&self.conversion));
root.insert("upstream", Value::s(&self.upstream));
root.insert("wrapper", Value::s(&self.wrapper));
root.insert("ecosystem", Value::s(&self.ecosystem));
root.insert("mode", Value::s(self.mode.as_str()));
let mut gates = Value::obj();
gates.insert("A", Value::s(if self.gate_a { "pass" } else { "fail" }));
gates.insert("B", Value::s(if self.gate_b { "pass" } else { "fail" }));
root.insert("gates", gates);
if let Some(err) = &self.error {
root.insert("error", Value::s(err));
}
if let Some(path) = &self.forged_path {
root.insert("forged-path", Value::s(path));
}
crate::json_ast::render(&root)
}
}
pub fn execute(spec: &OssConvSpec, out_root: &Path, allow_publish: bool) -> Attestation {
let mut att = Attestation {
conversion: spec.conversion.clone(),
upstream: spec.upstream.clone(),
wrapper: spec.wrapper.clone(),
ecosystem: String::new(),
mode: spec.publish_mode.clone(),
gate_a: false,
gate_b: false,
error: None,
forged_path: None,
};
let detected = match crate::discover::detect_github_url(&spec.upstream) {
Some(d) => d,
None => {
att.error = Some(format!("discover returned no match for {}", spec.upstream));
return att;
}
};
att.ecosystem = detected.ecosystem.to_string();
if detected.ecosystem != spec.expect_ecosystem {
att.error = Some(format!(
"Gate A: expected ecosystem {:?}, discovered {:?}",
spec.expect_ecosystem, detected.ecosystem
));
return att;
}
att.gate_a = true;
let target = out_root.join(&spec.wrapper);
if let Err(e) = std::fs::create_dir_all(&target) {
att.error = Some(format!("create_dir_all failed: {e}"));
return att;
}
let mut sspec = crate::scaffold::ScaffoldSpec::new(&spec.wrapper, &spec.expect_ecosystem);
sspec.description = spec.description.clone();
sspec.license = spec.license.clone();
sspec.version = spec.version.clone();
let mut repo_url = String::from("https://github.com/");
repo_url.push_str(&spec.publish_org); repo_url.push('/'); repo_url.push_str(&spec.wrapper);
sspec.repository = Some(repo_url);
let src = crate::ast::Render::render(&crate::scaffold::build(&sspec));
let caixa_path = target.join(format!("{}.caixa.lisp", spec.wrapper));
if let Err(e) = std::fs::write(&caixa_path, &src) {
att.error = Some(format!("write caixa: {e}"));
return att;
}
if let Err(e) = crate::caixa::render(&src, &target, true) {
att.error = Some(format!("caixa::render failed: {e}"));
return att;
}
att.forged_path = target.to_str().map(String::from);
let validator = crate::validator::validator_for(&spec.expect_ecosystem);
let issues = validator.validate(&target);
if !issues.is_empty() {
let mut msg = String::from("Gate B failed: ");
let first = issues.iter().take(3).map(|i| i.message.as_str())
.collect::<Vec<_>>().join("; ");
msg.push_str(&first);
if issues.len() > 3 {
msg.push_str(&format!(" (+{} more)", issues.len() - 3));
}
att.error = Some(msg);
return att;
}
att.gate_b = true;
if matches!(spec.publish_mode, PublishMode::Public | PublishMode::Private) {
if !allow_publish {
att.error = Some("publish requested but --yes not passed".into());
return att;
}
let visibility = if spec.publish_mode == PublishMode::Private {
"--private"
} else {
"--public"
};
let slug = format!("{}/{}", spec.publish_org, spec.wrapper);
let _ = std::process::Command::new("git").arg("init").arg("-q")
.current_dir(&target).status();
let _ = std::process::Command::new("git").args(["add", "-A"])
.current_dir(&target).status();
let _ = std::process::Command::new("git")
.args(["commit", "-q", "-m", "init: caixa-oss-conversion-agent forge"])
.current_dir(&target).status();
let st = std::process::Command::new("gh")
.args(["repo", "create", &slug, visibility, "--source=.", "--push"])
.current_dir(&target).status();
if !matches!(st, Ok(s) if s.success()) {
att.error = Some(format!("Step 6: gh repo create failed for {slug}"));
return att;
}
}
att
}
#[cfg(test)]
mod tests {
use super::*;
const EXAMPLE: &str = r#"
;; comment line
(defossconv pleme-io-wrap-foo
:upstream "tokio-rs/tracing"
:wrapper "wrap-foo"
:rationale "wrap a thing"
:expect-ecosystem :rust-workspace
:description "demo wrap"
:license "MIT"
:version "0.1.0"
:publish-org "pleme-io"
:publish-mode :dry-run)
"#;
#[test]
fn parses_minimal_form() {
let s = parse_str(EXAMPLE).expect("parse");
assert_eq!(s.conversion, "pleme-io-wrap-foo");
assert_eq!(s.upstream, "tokio-rs/tracing");
assert_eq!(s.wrapper, "wrap-foo");
assert_eq!(s.expect_ecosystem, "rust-workspace");
assert_eq!(s.description.as_deref(), Some("demo wrap"));
assert_eq!(s.license.as_deref(), Some("MIT"));
assert_eq!(s.version.as_deref(), Some("0.1.0"));
assert_eq!(s.publish_org, "pleme-io");
assert_eq!(s.publish_mode, PublishMode::DryRun);
}
#[test]
fn missing_required_slot_errors_clearly() {
let bad = "(defossconv x :upstream \"a/b\")";
let err = parse_str(bad).unwrap_err().to_string();
assert!(err.contains("missing :wrapper") || err.contains("missing :expect-ecosystem"),
"got: {err}");
}
#[test]
fn attestation_renders_typed_json() {
let att = Attestation {
conversion: "x".into(), upstream: "o/r".into(), wrapper: "w".into(),
ecosystem: "rust-single-crate".into(),
mode: PublishMode::DryRun,
gate_a: true, gate_b: true,
error: None, forged_path: Some("./out/w".into()),
};
let json = att.to_json();
assert!(json.contains("\"conversion\": \"x\""));
assert!(json.contains("\"mode\": \"dry-run\""));
assert!(json.contains("\"A\": \"pass\""));
assert!(json.contains("\"forged-path\": \"./out/w\""));
}
#[test]
fn publish_mode_parser_accepts_with_and_without_leading_colon() {
assert_eq!(PublishMode::parse(":dry-run").unwrap(), PublishMode::DryRun);
assert_eq!(PublishMode::parse("dry-run").unwrap(), PublishMode::DryRun);
assert_eq!(PublishMode::parse(":public").unwrap(), PublishMode::Public);
assert!(PublishMode::parse("nonsense").is_err());
}
}