use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct ShipReport {
pub target_slug: String,
pub rendered_path: PathBuf,
pub steps_executed: Vec<String>,
pub repo_url: Option<String>,
pub commit_sha: Option<String>,
pub workflow_run_url: Option<String>,
pub dry_run: bool,
}
impl ShipReport {
pub fn to_json(&self) -> String {
use crate::json_ast::Value;
let mut o = Value::obj();
o.insert("target", Value::s(&self.target_slug));
o.insert("rendered", Value::s(self.rendered_path.to_string_lossy().to_string()));
o.insert("dry-run", Value::b(self.dry_run));
o.insert("steps", Value::Array(self.steps_executed.iter().map(Value::s).collect()));
if let Some(u) = &self.repo_url { o.insert("repo-url", Value::s(u)); }
if let Some(c) = &self.commit_sha { o.insert("commit-sha", Value::s(c)); }
if let Some(w) = &self.workflow_run_url { o.insert("workflow-run-url", Value::s(w)); }
crate::json_ast::render(&o)
}
}
#[derive(Debug, Clone)]
pub struct ShipConfig {
pub visibility: Visibility,
pub description: String,
pub commit_message: String,
pub branch: String,
}
#[derive(Debug, Clone, Copy)]
pub enum Visibility {
Public, Private, Internal,
}
impl Visibility {
fn as_flag(self) -> &'static str {
match self {
Self::Public => "--public",
Self::Private => "--private",
Self::Internal => "--internal",
}
}
}
impl Default for ShipConfig {
fn default() -> Self {
Self {
visibility: Visibility::Public,
description: "Eaten by pleme-doc-gen substrate".to_string(),
commit_message: "feat: initial eat from substrate".to_string(),
branch: "main".to_string(),
}
}
}
pub fn ship(
target_slug: &str,
rendered_path: &Path,
cfg: &ShipConfig,
dry_run: bool,
) -> Result<ShipReport> {
let mut report = ShipReport {
target_slug: target_slug.to_string(),
rendered_path: rendered_path.to_path_buf(),
steps_executed: Vec::new(),
repo_url: None,
commit_sha: None,
workflow_run_url: None,
dry_run,
};
if !rendered_path.is_dir() {
return Err(anyhow!("rendered path is not a directory: {}", rendered_path.display()));
}
let steps = vec![
format!("gh repo create {target_slug} {} --description {:?}",
cfg.visibility.as_flag(), cfg.description),
format!("git -C {} init -q -b {}", rendered_path.display(), cfg.branch),
format!("git -C {} add -A", rendered_path.display()),
format!("git -C {} commit -q -m {:?}", rendered_path.display(), cfg.commit_message),
format!("git -C {} remote add origin git@github.com:{target_slug}.git",
rendered_path.display()),
format!("git -C {} push -u origin {}", rendered_path.display(), cfg.branch),
];
if dry_run {
for s in &steps {
report.steps_executed.push(format!("DRY-RUN: {s}"));
}
return Ok(report);
}
run("gh", &["repo", "create", target_slug,
cfg.visibility.as_flag(),
"--description", &cfg.description],
None, &mut report, &steps[0])?;
report.repo_url = Some(format!("https://github.com/{target_slug}"));
let _ = run("git", &["init", "-q", "-b", &cfg.branch],
Some(rendered_path), &mut report, &steps[1]);
run("git", &["add", "-A"], Some(rendered_path), &mut report, &steps[2])?;
run("git", &["commit", "-q", "-m", &cfg.commit_message],
Some(rendered_path), &mut report, &steps[3])?;
let _ = run("git", &["remote", "add", "origin",
&format!("git@github.com:{target_slug}.git")],
Some(rendered_path), &mut report, &steps[4]);
run("git", &["push", "-u", "origin", &cfg.branch],
Some(rendered_path), &mut report, &steps[5])?;
let sha = Command::new("git").args(["rev-parse", "HEAD"])
.current_dir(rendered_path).output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
report.commit_sha = sha;
report.workflow_run_url = Some(format!(
"https://github.com/{target_slug}/actions/workflows/auto-release.yml"));
Ok(report)
}
fn run(
bin: &str,
args: &[&str],
cwd: Option<&Path>,
report: &mut ShipReport,
step_label: &str,
) -> Result<()> {
let mut cmd = Command::new(bin);
cmd.args(args);
if let Some(c) = cwd { cmd.current_dir(c); }
let status = cmd.status()
.map_err(|e| anyhow!("spawn {bin}: {e}"))?;
if !status.success() {
return Err(anyhow!("step failed (exit {}): {step_label}",
status.code().unwrap_or(-1)));
}
report.steps_executed.push(step_label.to_string());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dry_run_emits_all_steps_without_executing() {
let tmp = tempdir::TempDir::new("ship").unwrap();
let cfg = ShipConfig::default();
let r = ship("test-org/test-repo", tmp.path(), &cfg, true).unwrap();
assert!(r.dry_run);
assert_eq!(r.steps_executed.len(), 6);
assert!(r.steps_executed[0].contains("gh repo create test-org/test-repo"));
assert!(r.steps_executed[5].contains("push -u origin"));
assert!(r.repo_url.is_none(), "dry-run shouldn't populate repo_url");
}
#[test]
fn dry_run_validates_rendered_dir_exists() {
let cfg = ShipConfig::default();
let err = ship("x/y", Path::new("/nonexistent/path"), &cfg, true).unwrap_err();
assert!(err.to_string().contains("not a directory"));
}
#[test]
fn visibility_flag_serializes_correctly() {
assert_eq!(Visibility::Public.as_flag(), "--public");
assert_eq!(Visibility::Private.as_flag(), "--private");
assert_eq!(Visibility::Internal.as_flag(), "--internal");
}
}