pleme-doc-gen 0.1.54

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_deps — typed resolver for the `:depends-on` slot.
//!
//! Closes the inheritance loop: a consumer caixa with
//!   :depends-on ["pleme-io/caixa-fd@main"]
//! generates `.caixa-deps/MANIFEST.txt` at render time; this verb
//! consumes that manifest, clones each `owner/repo@rev` to
//! `.caixa-deps/<safe-name>/clone/`, finds the .caixa.lisp at HEAD,
//! renders it into `.caixa-deps/<safe-name>/working/` so the consumer
//! can USE the dep's source.
//!
//! Per ★★ CSE compounding directive: extends an existing primitive
//! (caixa::render) — no new emitters, just typed orchestration over
//! gh-fetched git refs.

use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
use std::process::Command;

#[derive(Debug, Clone)]
pub struct DepSpec {
    /// Full slug as it appears in MANIFEST.txt (`owner/repo@rev` or
    /// `owner/repo` defaulting to `main`).
    pub raw: String,
    pub owner: String,
    pub repo: String,
    pub rev: String,
    /// Safe directory name for the materialized tree.
    pub safe_name: String,
}

impl DepSpec {
    pub fn parse(line: &str) -> Option<Self> {
        let raw = line.trim().to_string();
        if raw.is_empty() || raw.starts_with('#') { return None; }
        let (slug, rev) = match raw.split_once('@') {
            Some((s, r)) => (s.to_string(), r.to_string()),
            None         => (raw.clone(), "main".to_string()),
        };
        let mut parts = slug.splitn(2, '/');
        let owner = parts.next()?.to_string();
        let repo = parts.next()?.to_string();
        if owner.is_empty() || repo.is_empty() { return None; }
        let safe_name = crate::caixa_naming::sanitize_repo_name(&repo);
        Some(DepSpec { raw, owner, repo, rev, safe_name })
    }
}

#[derive(Debug, Clone)]
pub struct DepResolution {
    pub spec: DepSpec,
    pub clone_path: Option<PathBuf>,
    pub caixa_lisp_path: Option<PathBuf>,
    pub working_path: Option<PathBuf>,
    pub rendered_count: usize,
    pub error: Option<String>,
}

impl DepResolution {
    pub fn success(&self) -> bool { self.error.is_none() && self.working_path.is_some() }
}

#[derive(Debug, Clone, Default)]
pub struct ResolveReport {
    pub manifest_path: PathBuf,
    pub deps_dir: PathBuf,
    pub resolved: Vec<DepResolution>,
    pub success_count: usize,
    pub failure_count: usize,
}

impl ResolveReport {
    pub fn to_json(&self) -> String {
        use crate::json_ast::Value;
        let mut o = Value::obj();
        o.insert("manifest", Value::s(self.manifest_path.to_string_lossy().to_string()));
        o.insert("deps-dir", Value::s(self.deps_dir.to_string_lossy().to_string()));
        o.insert("success-count", Value::i(self.success_count as i64));
        o.insert("failure-count", Value::i(self.failure_count as i64));
        let rows: Vec<Value> = self.resolved.iter().map(|r| {
            let mut row = Value::obj();
            row.insert("slug", Value::s(&r.spec.raw));
            row.insert("safe-name", Value::s(&r.spec.safe_name));
            row.insert("rev", Value::s(&r.spec.rev));
            row.insert("success", Value::b(r.success()));
            if let Some(p) = &r.working_path {
                row.insert("working", Value::s(p.to_string_lossy().to_string()));
            }
            if r.rendered_count > 0 {
                row.insert("rendered", Value::i(r.rendered_count as i64));
            }
            if let Some(e) = &r.error { row.insert("error", Value::s(e)); }
            row
        }).collect();
        o.insert("deps", Value::Array(rows));
        crate::json_ast::render(&o)
    }
}

#[derive(Debug, Clone)]
pub struct ResolveConfig {
    /// Recurse into materialized deps' own .caixa-deps/MANIFEST.txt.
    pub transitive: bool,
    /// Force re-clone + re-render even if .caixa-deps/<name>/ exists.
    pub force: bool,
    /// Max recursion depth for transitive resolution.
    pub max_depth: usize,
}

impl Default for ResolveConfig {
    fn default() -> Self {
        Self { transitive: true, force: false, max_depth: 5 }
    }
}

/// Resolve a target's .caixa-deps/MANIFEST.txt — fetch each dep, find
/// its .caixa.lisp, render into a working tree. Returns the typed
/// ResolveReport with per-dep outcomes.
pub fn resolve(target_dir: &Path, cfg: &ResolveConfig) -> Result<ResolveReport> {
    resolve_inner(target_dir, cfg, 0)
}

fn resolve_inner(target_dir: &Path, cfg: &ResolveConfig, depth: usize) -> Result<ResolveReport> {
    let manifest_path = target_dir.join(".caixa-deps").join("MANIFEST.txt");
    let deps_dir = target_dir.join(".caixa-deps");
    let mut report = ResolveReport {
        manifest_path: manifest_path.clone(),
        deps_dir: deps_dir.clone(),
        ..Default::default()
    };
    if !manifest_path.is_file() {
        return Ok(report); // no deps to resolve — empty success report
    }
    let text = std::fs::read_to_string(&manifest_path)?;
    let specs: Vec<DepSpec> = text.lines().filter_map(DepSpec::parse).collect();
    for spec in specs {
        let mut res = DepResolution {
            spec: spec.clone(),
            clone_path: None, caixa_lisp_path: None, working_path: None,
            rendered_count: 0, error: None,
        };
        match materialize_one(&spec, &deps_dir, cfg) {
            Ok((clone, lisp, working, count)) => {
                res.clone_path = Some(clone);
                res.caixa_lisp_path = Some(lisp);
                res.working_path = Some(working.clone());
                res.rendered_count = count;
                report.success_count += 1;
                // Transitive: recurse into the materialized dep's own
                // .caixa-deps/MANIFEST.txt up to max_depth.
                if cfg.transitive && depth + 1 < cfg.max_depth {
                    let sub = resolve_inner(&working, cfg, depth + 1)?;
                    for r in sub.resolved { report.resolved.push(r); }
                    report.success_count += sub.success_count;
                    report.failure_count += sub.failure_count;
                }
            }
            Err(e) => {
                res.error = Some(e.to_string());
                report.failure_count += 1;
            }
        }
        report.resolved.push(res);
    }
    Ok(report)
}

/// Fetch + render one dep. Returns (clone_path, caixa_lisp_path,
/// working_path, rendered_artifact_count).
fn materialize_one(
    spec: &DepSpec,
    deps_dir: &Path,
    cfg: &ResolveConfig,
) -> Result<(PathBuf, PathBuf, PathBuf, usize)> {
    let dep_root = deps_dir.join(&spec.safe_name);
    let clone_path = dep_root.join("clone");
    let working_path = dep_root.join("working");

    // Step 1 — clone (shallow). Idempotent: skip if dir exists + not --force.
    if clone_path.is_dir() && !cfg.force {
        // already materialized; trust existing tree
    } else {
        if clone_path.exists() { std::fs::remove_dir_all(&clone_path)?; }
        std::fs::create_dir_all(&dep_root)?;
        let url = format!("https://github.com/{}/{}.git", spec.owner, spec.repo);
        let st = Command::new("git")
            .args(["clone", "--depth", "1",
                   "--branch", &spec.rev,
                   "--quiet", &url, clone_path.to_str().unwrap()])
            .status()
            .map_err(|e| anyhow!("git clone {}: {e}", spec.raw))?;
        if !st.success() {
            return Err(anyhow!("git clone {} non-zero", spec.raw));
        }
    }

    // Step 2 — find a .caixa.lisp at clone root.
    let lisp_path = find_caixa_lisp(&clone_path)
        .ok_or_else(|| anyhow!("no .caixa.lisp found in {}", spec.raw))?;

    // Step 3 — render the dep's working tree from its .caixa.lisp.
    // force=true so re-resolves overwrite stale renders.
    if working_path.exists() && cfg.force {
        std::fs::remove_dir_all(&working_path)?;
    }
    std::fs::create_dir_all(&working_path)?;
    let lisp_src = std::fs::read_to_string(&lisp_path)?;
    let rendered = crate::caixa::render(&lisp_src, &working_path, true)?;

    Ok((clone_path, lisp_path, working_path, rendered.len()))
}

/// Find a single `*.caixa.lisp` at the clone root. Returns the first
/// match; None when absent. (For eaten upstream repos, the .caixa.lisp
/// lives at root.)
fn find_caixa_lisp(clone_path: &Path) -> Option<PathBuf> {
    let entries = std::fs::read_dir(clone_path).ok()?;
    for entry in entries.flatten() {
        let path = entry.path();
        if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
            if name.ends_with(".caixa.lisp") {
                return Some(path);
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn dep_spec_parses_default_main_rev() {
        let s = DepSpec::parse("pleme-io/caixa-fd").unwrap();
        assert_eq!(s.owner, "pleme-io");
        assert_eq!(s.repo, "caixa-fd");
        assert_eq!(s.rev, "main");
        assert_eq!(s.safe_name, "caixa-fd");
    }

    #[test]
    fn dep_spec_parses_explicit_rev() {
        let s = DepSpec::parse("pleme-io/caixa-fd@v1.2.3").unwrap();
        assert_eq!(s.rev, "v1.2.3");
    }

    #[test]
    fn dep_spec_ignores_comments_and_empty() {
        assert!(DepSpec::parse("# a comment").is_none());
        assert!(DepSpec::parse("").is_none());
        assert!(DepSpec::parse("   ").is_none());
    }

    #[test]
    fn dep_spec_rejects_invalid_slugs() {
        assert!(DepSpec::parse("just-a-name").is_none());
        assert!(DepSpec::parse("/no-owner").is_none());
        assert!(DepSpec::parse("no-repo/").is_none());
    }

    #[test]
    fn resolve_returns_empty_report_when_no_manifest() {
        let tmp = tempdir::TempDir::new("res").unwrap();
        let report = resolve(tmp.path(), &ResolveConfig::default()).unwrap();
        assert_eq!(report.success_count, 0);
        assert_eq!(report.failure_count, 0);
        assert!(report.resolved.is_empty());
    }
}