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.
//! ship — push an eaten rendered dir to a new GitHub repo.
//!
//! Per the operator's "sailed out to final package destination
//! which for caixa is git itself" directive: the substrate's
//! penultimate lifecycle stage. Takes an eaten dir (output of eat),
//! creates a new GH repo via `gh repo create`, initializes git,
//! commits the eaten tree, pushes. The auto-release.yml in the
//! pushed scaffold then triggers CI on the remote — that's the
//! `await-ci` verb's job to observe.
//!
//! Safety: ship requires explicit --yes. Default mode prints the
//! plan + exits. Creating + pushing to remote repos is high-blast-
//! radius and operator must opt in.

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 {
    /// Visibility of the new repo. Default: public.
    pub visibility: Visibility,
    /// Description for the new repo. Default: "Eaten by pleme-doc-gen".
    pub description: String,
    /// Initial commit message.
    pub commit_message: String,
    /// Default branch name.
    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(),
        }
    }
}

/// Top-level ship operation.
///
/// `dry_run: true` builds the plan without executing — operators
/// inspect before committing. `dry_run: false` runs every step:
///   1. gh repo create <slug> --<visibility> --description "..."
///   2. git init in rendered_path (idempotent)
///   3. git add .
///   4. git commit -m "<message>"
///   5. git remote add origin git@github.com:<slug>.git
///   6. git push -u origin <branch>
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);
    }

    // 1. gh repo create
    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}"));

    // 2. git init (allow already-initialized)
    let _ = run("git", &["init", "-q", "-b", &cfg.branch],
                Some(rendered_path), &mut report, &steps[1]);

    // 3. git add -A
    run("git", &["add", "-A"], Some(rendered_path), &mut report, &steps[2])?;

    // 4. git commit
    run("git", &["commit", "-q", "-m", &cfg.commit_message],
        Some(rendered_path), &mut report, &steps[3])?;

    // 5. git remote add origin (ignore failure when remote exists)
    let _ = run("git", &["remote", "add", "origin",
                         &format!("git@github.com:{target_slug}.git")],
                Some(rendered_path), &mut report, &steps[4]);

    // 6. git push
    run("git", &["push", "-u", "origin", &cfg.branch],
        Some(rendered_path), &mut report, &steps[5])?;

    // Capture commit sha for the report.
    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;
    // Workflow run URL is derived; await-ci verb resolves the actual
    // run id later.
    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");
    }
}