pleme-doc-gen 0.1.52

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.
//! eat_and_ship — typed orchestrator chaining the full lifecycle.
//!
//! Composes existing substrate primitives into one operator-facing
//! verb that takes a source path + target slug + executes:
//!
//!   1. eat        — absorb + reverse + render + restore
//!   2. verify-tests — ecosystem-appropriate local test runner
//!   3. ship       — gh repo create + git push (gated by --yes)
//!   4. await-ci   — observe auto-release workflow on the remote
//!
//! Each stage's typed Report flows into the combined LifecycleReport.
//! Operators read one JSON to see the entire eat→published trajectory.

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

#[derive(Debug, Clone)]
pub struct LifecycleConfig {
    /// Skip local verify-tests stage (faster — operator already verified).
    pub skip_verify: bool,
    /// Skip ship + await-ci stages (eat-only mode).
    pub skip_ship: bool,
    /// Skip await-ci after ship (push-only mode).
    pub skip_await: bool,
    /// ship config (visibility, description, etc).
    pub ship: crate::ship::ShipConfig,
    /// await-ci config (timeout, poll, workflow).
    pub await_cfg: crate::await_ci::AwaitConfig,
    /// file_capture config (size caps, skip-dirs).
    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()));
        // Per-stage typed reports are emitted as embedded JSON strings
        // — operators inspect each stage's full detail when needed.
        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)
    }
}

/// Top-level orchestrator.
///
/// `dry_run`: when true, ship operates in plan-only mode + await-ci
/// is skipped. Use for safe inspection before committing to the real
/// push.
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(),
    };

    // Stage 1 — eat
    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();

    // Stage 2 — verify-tests (optional)
    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); // halt — broken tests gate the rest
            }
            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());
    }

    // Stage 3 — ship
    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();

    // Stage 4 — await-ci (only when ship really executed)
    if cfg.skip_await {
        report.stages_completed.push("await-ci-skipped".to_string());
        report.final_status = "shipped-no-await".to_string();
        return Ok(report);
    }
    // If the renderer intentionally omitted auto-release.yml (the
    // post-restoration guard fires for rust crates with no bumpable
    // version source — rust-url / bindgen / dirs-next pattern), there
    // IS no workflow to await. Surface that as a distinct lifecycle
    // terminal state rather than the misleading ci-not-success.
    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; // synthetic test — substrate's smoke would still pass but skip for speed
        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");
        // No await-ci on 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"));
    }
}