use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct DepSpec {
pub raw: String,
pub owner: String,
pub repo: String,
pub rev: String,
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 {
pub transitive: bool,
pub force: bool,
pub max_depth: usize,
}
impl Default for ResolveConfig {
fn default() -> Self {
Self { transitive: true, force: false, max_depth: 5 }
}
}
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); }
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;
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)
}
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");
if clone_path.is_dir() && !cfg.force {
} 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));
}
}
let lisp_path = find_caixa_lisp(&clone_path)
.ok_or_else(|| anyhow!("no .caixa.lisp found in {}", spec.raw))?;
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()))
}
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());
}
}