pleme-doc-gen 0.1.40

Rust replacement for the M0 Python _gen-patterns.py + _gen-docs.py scripts in pleme-io/actions. Walks every action.yml + emits substrate's patterns-full.nix + per-action README.md + root catalog. Per the NO-SHELL prime directive.
//! caixa-forge fleet — N-caixa explosion in one operator action.
//!
//! Per the ★★ CLOSED-LOOP MASS-SYNTHESIS directive Rule 2 (closed-
//! loop primitive composition at the CLI layer) + the operator's
//! "edge of an explosion of producing and generating entire libraries
//! and suites of behavior" direction:
//!
//! ONE typed (defcaixa-fleet …) lisp form describes a whole
//! architecture suite (N caixas with typed relations). ONE CLI call
//! (`pleme-doc-gen fleet-forge --source X.fleet.lisp`) instantiates
//! every member through the same forge pipeline that already exists.
//!
//! Form shape:
//!
//!   (defcaixa-fleet pleme-io-observability-stack
//!     :description "Typed observability stack for pleme-io fleets"
//!     :caixas [
//!       {:name "vector-config"     :ecosystem :helm
//!        :description "Vector pipeline typed values"}
//!       {:name "otel-collector"    :ecosystem :rust-single-crate
//!        :description "Typed OTel collector wrapper"}
//!       {:name "grafana-dash"      :ecosystem :helm
//!        :description "Operator-facing Grafana dashboards"}])
//!
//! Each member explodes into a forge pass — typed .caixa.lisp
//! persisted + 9+ rendered artifacts per member + autobump-wiring
//! ready for git push → autorelease cascade.

use anyhow::{anyhow, bail, Result};
use std::path::Path;

/// Typed view of a (defcaixa-fleet …) form. Each member becomes
/// one forge invocation.
#[derive(Debug, Clone)]
pub struct FleetSpec {
    pub name: String,
    pub description: Option<String>,
    pub members: Vec<FleetMember>,
}

/// One member of a fleet — what becomes a single caixa.
#[derive(Debug, Clone)]
pub struct FleetMember {
    pub name: String,
    pub ecosystem: String,
    pub description: Option<String>,
    pub license: Option<String>,
    pub version: Option<String>,
}

/// The typed receipt for one fleet-forge run.
#[derive(Debug, Clone)]
pub struct FleetReport {
    pub name: String,
    pub total: usize,
    pub ok: usize,
    pub failed: Vec<(String, String)>, // (member-name, error)
    pub forged: Vec<(String, std::path::PathBuf)>, // (member-name, path)
}

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)
    }
}

/// Parse a .fleet.lisp source into FleetSpec. The fleet form's body
/// has fixed slots; the :caixas vector contains member maps.
pub fn parse_str(src: &str) -> Result<FleetSpec> {
    // Strip comments
    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 })
}

/// Read a `:slot "string-value"` from anywhere in the form.
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())
}

/// Read the :caixas vector of member maps. Members are `{:name "x"
/// :ecosystem :y :description "..."}` blocks.
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;
    // Find matching `]`.
    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'"' => {
                // Skip string literal
                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];

    // Parse member maps. Each `{ … }` block is one member.
    let mut members = Vec::new();
    let mb = body.as_bytes();
    let mut p = 0usize;
    while p < mb.len() {
        // Skip whitespace
        while p < mb.len() && (mb[p] as char).is_whitespace() { p += 1; }
        if p >= mb.len() { break; }
        if mb[p] != b'{' { p += 1; continue; }
        // Find matching `}` accounting for strings.
        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)
}

/// Parse `:name "x" :ecosystem :y :description "..."` inside `{ }`.
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; }
        // Read keyword
        let kw_start = p;
        while p < bytes.len() && !(bytes[p] as char).is_whitespace() {
            p += 1;
        }
        let kw = &body[kw_start..p];
        // Skip whitespace
        while p < bytes.len() && (bytes[p] as char).is_whitespace() { p += 1; }
        if p >= bytes.len() { break; }
        // Read value: quoted string OR :keyword OR bare
        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),
            _ => {} // tolerate unknown
        }
    }
    if m.name.is_empty() { bail!("member missing :name"); }
    if m.ecosystem.is_empty() { bail!("member {} missing :ecosystem", m.name); }
    Ok(m)
}

/// Execute fleet-forge: for each member, run the scaffold→forge
/// pipeline + persist the .caixa.lisp into <out_root>/<member.name>/.
/// Failures aggregate; one bad member doesn't abort the others.
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\""));
    }
}