use anyhow::{anyhow, bail, Result};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct FleetSpec {
pub name: String,
pub description: Option<String>,
pub members: Vec<FleetMember>,
}
#[derive(Debug, Clone)]
pub struct FleetMember {
pub name: String,
pub ecosystem: String,
pub description: Option<String>,
pub license: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Clone)]
pub struct FleetReport {
pub name: String,
pub total: usize,
pub ok: usize,
pub failed: Vec<(String, String)>, pub forged: Vec<(String, std::path::PathBuf)>, }
impl FleetReport {
pub fn to_json(&self) -> String {
use crate::json_ast::Value;
let mut root = Value::obj();
root.insert("fleet", Value::s(&self.name));
root.insert("total", Value::i(self.total as i64));
root.insert("ok", Value::i(self.ok as i64));
root.insert("failed-count", Value::i(self.failed.len() as i64));
let forged: Vec<Value> = self.forged.iter().map(|(n, p)| {
let mut o = Value::obj();
o.insert("name", Value::s(n));
o.insert("path", Value::s(p.to_string_lossy().to_string()));
o
}).collect();
root.insert("forged", Value::Array(forged));
let failed: Vec<Value> = self.failed.iter().map(|(n, err)| {
let mut o = Value::obj();
o.insert("name", Value::s(n));
o.insert("error", Value::s(err));
o
}).collect();
root.insert("failed", Value::Array(failed));
crate::json_ast::render(&root)
}
}
pub fn parse_str(src: &str) -> Result<FleetSpec> {
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 = trimmed.strip_prefix('(')
.ok_or_else(|| anyhow!("expected '(' at start"))?;
let mut tokens = after.split_whitespace();
let head = tokens.next().ok_or_else(|| anyhow!("empty form"))?;
if head != "defcaixa-fleet" {
bail!("expected (defcaixa-fleet …) form, got ({head} …)");
}
let name = tokens.next()
.ok_or_else(|| anyhow!("missing fleet name"))?
.to_string();
let description = read_string_slot(&cleaned, ":description");
let members = read_caixas_vector(&cleaned)?;
Ok(FleetSpec { name, description, members })
}
fn read_string_slot(src: &str, slot: &str) -> Option<String> {
let pos = src.find(slot)?;
let after = &src[pos + slot.len()..];
let after = after.trim_start();
let after = after.strip_prefix('"')?;
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn read_caixas_vector(src: &str) -> Result<Vec<FleetMember>> {
let kw_pos = src.find(":caixas").ok_or_else(|| anyhow!("missing :caixas"))?;
let after = &src[kw_pos + ":caixas".len()..];
let bracket = after.find('[').ok_or_else(|| anyhow!(":caixas value must be a vector"))?;
let body_start = bracket + 1;
let bytes = after.as_bytes();
let mut depth = 1i32;
let mut i = body_start;
while i < bytes.len() {
match bytes[i] {
b'[' => depth += 1,
b']' => { depth -= 1; if depth == 0 { break; } }
b'"' => {
i += 1;
while i < bytes.len() {
if bytes[i] == b'\\' { i += 2; continue; }
if bytes[i] == b'"' { break; }
i += 1;
}
}
_ => {}
}
i += 1;
}
let body = &after[body_start..i];
let mut members = Vec::new();
let mb = body.as_bytes();
let mut p = 0usize;
while p < mb.len() {
while p < mb.len() && (mb[p] as char).is_whitespace() { p += 1; }
if p >= mb.len() { break; }
if mb[p] != b'{' { p += 1; continue; }
let map_start = p + 1;
let mut md = 1i32;
let mut q = map_start;
while q < mb.len() {
match mb[q] {
b'{' => md += 1,
b'}' => { md -= 1; if md == 0 { break; } }
b'"' => {
q += 1;
while q < mb.len() {
if mb[q] == b'\\' { q += 2; continue; }
if mb[q] == b'"' { break; }
q += 1;
}
}
_ => {}
}
q += 1;
}
let map_body = &body[map_start..q];
members.push(parse_member_map(map_body)?);
p = q + 1;
}
Ok(members)
}
fn parse_member_map(body: &str) -> Result<FleetMember> {
let mut m = FleetMember {
name: String::new(),
ecosystem: String::new(),
description: None,
license: None,
version: None,
};
let bytes = body.as_bytes();
let mut p = 0usize;
while p < bytes.len() {
while p < bytes.len() && (bytes[p] as char).is_whitespace() { p += 1; }
if p >= bytes.len() { break; }
if bytes[p] != b':' { p += 1; continue; }
let kw_start = p;
while p < bytes.len() && !(bytes[p] as char).is_whitespace() {
p += 1;
}
let kw = &body[kw_start..p];
while p < bytes.len() && (bytes[p] as char).is_whitespace() { p += 1; }
if p >= bytes.len() { break; }
let (val, after_val) = if bytes[p] == b'"' {
let val_start = p + 1;
let mut i = val_start;
while i < bytes.len() {
if bytes[i] == b'\\' { i += 2; continue; }
if bytes[i] == b'"' { break; }
i += 1;
}
(body[val_start..i].to_string(), i + 1)
} else {
let val_start = p;
let mut i = val_start;
while i < bytes.len() && !(bytes[i] as char).is_whitespace() {
i += 1;
}
(body[val_start..i].to_string(), i)
};
p = after_val;
match kw {
":name" => m.name = val,
":ecosystem" => m.ecosystem = val.trim_start_matches(':').to_string(),
":description" => m.description = Some(val),
":license" => m.license = Some(val),
":version" => m.version = Some(val),
_ => {} }
}
if m.name.is_empty() { bail!("member missing :name"); }
if m.ecosystem.is_empty() { bail!("member {} missing :ecosystem", m.name); }
Ok(m)
}
pub fn execute(spec: &FleetSpec, out_root: &Path) -> FleetReport {
use crate::ast::Render;
let mut report = FleetReport {
name: spec.name.clone(),
total: spec.members.len(),
ok: 0,
failed: Vec::new(),
forged: Vec::new(),
};
let _ = std::fs::create_dir_all(out_root);
for m in &spec.members {
let target = out_root.join(&m.name);
let res = (|| -> Result<std::path::PathBuf> {
std::fs::create_dir_all(&target)?;
let mut sspec = crate::scaffold::ScaffoldSpec::new(&m.name, &m.ecosystem);
sspec.description = m.description.clone();
sspec.license = m.license.clone();
sspec.version = m.version.clone();
let src = crate::scaffold::build(&sspec).render();
std::fs::write(target.join(format!("{}.caixa.lisp", m.name)), &src)?;
crate::caixa::render(&src, &target, true)?;
Ok(target.clone())
})();
match res {
Ok(p) => {
report.ok += 1;
report.forged.push((m.name.clone(), p));
}
Err(e) => report.failed.push((m.name.clone(), e.to_string())),
}
}
report
}
#[cfg(test)]
mod tests {
use super::*;
const EXAMPLE: &str = r#"
;; demo fleet
(defcaixa-fleet pleme-io-observability-stack
:description "Typed observability stack"
:caixas [
{:name "vector-config" :ecosystem :helm :description "Vector pipeline"}
{:name "otel-collector" :ecosystem :rust-single-crate :description "Typed OTel wrapper"}
{:name "grafana-dash" :ecosystem :helm :description "Grafana dashboards"}])
"#;
#[test]
fn parses_three_member_fleet() {
let f = parse_str(EXAMPLE).expect("parse");
assert_eq!(f.name, "pleme-io-observability-stack");
assert_eq!(f.description.as_deref(), Some("Typed observability stack"));
assert_eq!(f.members.len(), 3);
assert_eq!(f.members[0].name, "vector-config");
assert_eq!(f.members[0].ecosystem, "helm");
assert_eq!(f.members[1].name, "otel-collector");
assert_eq!(f.members[1].ecosystem, "rust-single-crate");
assert_eq!(f.members[1].description.as_deref(), Some("Typed OTel wrapper"));
assert_eq!(f.members[2].name, "grafana-dash");
assert_eq!(f.members[2].ecosystem, "helm");
}
#[test]
fn missing_caixas_errors() {
let bad = "(defcaixa-fleet x :description \"y\")";
assert!(parse_str(bad).is_err());
}
#[test]
fn member_missing_ecosystem_errors() {
let bad = "(defcaixa-fleet x :caixas [ {:name \"a\"} ])";
assert!(parse_str(bad).is_err());
}
#[test]
fn report_serializes_typed_json() {
let r = FleetReport {
name: "x".into(), total: 2, ok: 1,
failed: vec![("b".into(), "boom".into())],
forged: vec![("a".into(), std::path::PathBuf::from("/tmp/a"))],
};
let j = r.to_json();
assert!(j.contains("\"fleet\": \"x\""));
assert!(j.contains("\"total\": 2"));
assert!(j.contains("\"ok\": 1"));
assert!(j.contains("\"failed-count\": 1"));
assert!(j.contains("\"name\": \"a\""));
assert!(j.contains("\"name\": \"b\""));
assert!(j.contains("\"error\": \"boom\""));
}
}