use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct LifecycleConfig {
pub skip_verify: bool,
pub skip_ship: bool,
pub skip_await: bool,
pub ship: crate::ship::ShipConfig,
pub await_cfg: crate::await_ci::AwaitConfig,
pub capture: crate::file_capture::CaptureConfig,
}
impl Default for LifecycleConfig {
fn default() -> Self {
Self {
skip_verify: false,
skip_ship: false,
skip_await: false,
ship: crate::ship::ShipConfig::default(),
await_cfg: crate::await_ci::AwaitConfig::default(),
capture: crate::file_capture::CaptureConfig::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct LifecycleReport {
pub source_path: PathBuf,
pub target_slug: String,
pub eat: Option<crate::eat::EatReport>,
pub verify_tests_passed: Option<bool>,
pub ship: Option<crate::ship::ShipReport>,
pub await_ci: Option<crate::await_ci::WaitReport>,
pub stages_completed: Vec<String>,
pub final_status: String,
}
impl LifecycleReport {
pub fn is_success(&self) -> bool {
self.final_status == "published-success" ||
self.final_status == "shipped-no-await" ||
self.final_status == "shipped-no-bumpable-version" ||
self.final_status == "eaten-no-ship"
}
pub fn to_json(&self) -> String {
use crate::json_ast::Value;
let mut o = Value::obj();
o.insert("source", Value::s(self.source_path.to_string_lossy().to_string()));
o.insert("target", Value::s(&self.target_slug));
o.insert("final-status", Value::s(&self.final_status));
o.insert("success", Value::b(self.is_success()));
o.insert("stages", Value::Array(self.stages_completed.iter().map(Value::s).collect()));
if let Some(e) = &self.eat {
o.insert("eat-summary", Value::Object(vec![
("captured-files".to_string(), Value::i(e.captured_file_count as i64)),
("captured-bytes".to_string(), Value::i(e.captured_bytes as i64)),
("rendered-artifacts".to_string(), Value::i(e.rendered_artifact_count as i64)),
]));
}
if let Some(v) = self.verify_tests_passed {
o.insert("verify-tests-passed", Value::b(v));
}
if let Some(s) = &self.ship {
o.insert("ship-summary", Value::Object(vec![
("dry-run".to_string(), Value::b(s.dry_run)),
("repo-url".to_string(), Value::s(s.repo_url.clone().unwrap_or_default())),
("commit-sha".to_string(), Value::s(s.commit_sha.clone().unwrap_or_default())),
]));
}
if let Some(a) = &self.await_ci {
o.insert("await-ci-summary", Value::Object(vec![
("status".to_string(), Value::s(&a.status)),
("conclusion".to_string(), Value::s(a.conclusion.clone().unwrap_or_default())),
("run-url".to_string(), Value::s(a.run_url.clone().unwrap_or_default())),
("waited-seconds".to_string(), Value::i(a.waited_seconds as i64)),
]));
}
crate::json_ast::render(&o)
}
}
pub fn eat_and_ship(
source: &Path,
out: &Path,
target_slug: &str,
cfg: &LifecycleConfig,
dry_run: bool,
) -> Result<LifecycleReport> {
let mut report = LifecycleReport {
source_path: source.to_path_buf(),
target_slug: target_slug.to_string(),
eat: None,
verify_tests_passed: None,
ship: None,
await_ci: None,
stages_completed: Vec::new(),
final_status: "starting".to_string(),
};
let eat_report = crate::eat::eat(source, out, &cfg.capture)?;
let rendered_path = eat_report.rendered_path.clone();
let ecosystem = eat_report.ecosystem.clone();
report.eat = Some(eat_report);
report.stages_completed.push("eat".to_string());
report.final_status = "eaten".to_string();
if !cfg.skip_verify {
let eco = ecosystem.clone()
.ok_or_else(|| anyhow!("eat produced no ecosystem — cannot dispatch tests"))?;
match run_verify_tests(&rendered_path, &eco) {
Ok(true) => {
report.verify_tests_passed = Some(true);
report.stages_completed.push("verify-tests".to_string());
report.final_status = "verified".to_string();
}
Ok(false) => {
report.verify_tests_passed = Some(false);
report.final_status = "verify-tests-failed".to_string();
return Ok(report); }
Err(e) => {
report.verify_tests_passed = Some(false);
report.final_status = format!("verify-tests-error: {e}");
return Ok(report);
}
}
} else {
report.stages_completed.push("verify-tests-skipped".to_string());
}
if cfg.skip_ship {
report.stages_completed.push("ship-skipped".to_string());
report.final_status = "eaten-no-ship".to_string();
return Ok(report);
}
let ship_report = crate::ship::ship(target_slug, &rendered_path, &cfg.ship, dry_run)?;
let dry = ship_report.dry_run;
report.ship = Some(ship_report);
report.stages_completed.push(if dry { "ship-dry-run".to_string() }
else { "ship".to_string() });
if dry {
report.final_status = "shipped-dry-run".to_string();
return Ok(report);
}
report.final_status = "shipped".to_string();
if cfg.skip_await {
report.stages_completed.push("await-ci-skipped".to_string());
report.final_status = "shipped-no-await".to_string();
return Ok(report);
}
let auto_release_yml = rendered_path.join(".github/workflows/auto-release.yml");
if !auto_release_yml.exists() {
report.stages_completed.push("await-ci-skipped-no-release-workflow".to_string());
report.final_status = "shipped-no-bumpable-version".to_string();
return Ok(report);
}
let wait = crate::await_ci::await_ci(target_slug, &cfg.await_cfg)?;
let success = wait.is_success();
report.await_ci = Some(wait);
report.stages_completed.push("await-ci".to_string());
report.final_status = if success { "published-success".to_string() }
else { "ci-not-success".to_string() };
Ok(report)
}
fn run_verify_tests(rendered: &Path, ecosystem: &str) -> Result<bool> {
let cmd = crate::test_command_for(ecosystem)
.ok_or_else(|| anyhow!("no test runner configured for `{ecosystem}`"))?;
let status = std::process::Command::new(&cmd[0])
.args(&cmd[1..])
.current_dir(rendered)
.status()
.map_err(|e| anyhow!("spawn {}: {e}", cmd[0]))?;
Ok(status.success())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn mk_repo(files: &[(&str, &str)]) -> tempdir::TempDir {
let tmp = tempdir::TempDir::new("eas-src").unwrap();
for (rel, body) in files {
let p = tmp.path().join(rel);
if let Some(parent) = p.parent() { fs::create_dir_all(parent).unwrap(); }
fs::write(&p, body).unwrap();
}
tmp
}
#[test]
fn eat_only_mode_with_skip_ship_completes_at_eaten() {
let src = mk_repo(&[
("Cargo.toml",
"[package]\nname = \"orch-test\"\nversion = \"0.1.0\"\nlicense = \"MIT\"\n"),
("src/lib.rs", "#[cfg(test)] mod t { #[test] fn s() {} }"),
]);
let out = tempdir::TempDir::new("eas-out").unwrap();
let mut cfg = LifecycleConfig::default();
cfg.skip_ship = true;
cfg.skip_verify = true; let report = eat_and_ship(src.path(), out.path(), "test/org-test", &cfg, false).unwrap();
assert!(report.eat.is_some());
assert_eq!(report.final_status, "eaten-no-ship");
assert!(report.is_success());
assert!(report.ship.is_none());
assert!(report.await_ci.is_none());
}
#[test]
fn dry_run_mode_emits_ship_plan_without_executing() {
let src = mk_repo(&[
("Cargo.toml",
"[package]\nname = \"dr-test\"\nversion = \"0.1.0\"\nlicense = \"MIT\"\n"),
("src/lib.rs", "#[cfg(test)] mod t { #[test] fn s() {} }"),
]);
let out = tempdir::TempDir::new("eas-out").unwrap();
let mut cfg = LifecycleConfig::default();
cfg.skip_verify = true;
let report = eat_and_ship(src.path(), out.path(), "test/dr-test", &cfg, true).unwrap();
assert!(report.ship.is_some());
assert!(report.ship.as_ref().unwrap().dry_run);
assert_eq!(report.final_status, "shipped-dry-run");
assert!(report.await_ci.is_none());
}
#[test]
fn fails_on_undetectable_source() {
let src = mk_repo(&[("data.txt", "no manifest")]);
let out = tempdir::TempDir::new("eas-out").unwrap();
let cfg = LifecycleConfig::default();
let err = eat_and_ship(src.path(), out.path(), "x/y", &cfg, true).unwrap_err();
assert!(err.to_string().contains("no ecosystem detected"));
}
}