rok-cli 0.3.2

Developer CLI for rok-based Axum applications
use std::path::Path;
use std::process::Command;
use std::time::Duration;

pub const PUBLISH_ORDER: &[&[&str]] = &[
    // Layer 0 — proc-macro crates
    &[
        "rok-acl-macros",
        "rok-audit-macros",
        "rok-auth-macros",
        "rok-config-macros",
        "rok-events-macros",
        "rok-orm-macros",
        "rok-queue-macros",
        "rok-search-macros",
        "rok-validate-macros",
    ],
    // Layer 1 — leaf runtime crates
    &[
        "rok-cache",
        "rok-cors",
        "rok-encrypt",
        "rok-feature",
        "rok-hash",
        "rok-health",
        "rok-i18n",
        "rok-ids",
        "rok-lock",
        "rok-mail",
        "rok-orm-core",
        "rok-orm-migrate",
        "rok-problem",
        "rok-rate-limit",
        "rok-router",
        "rok-shield",
        "rok-studio",
        "rok-telemetry",
        "rok-testing",
        "rok-websocket",
    ],
    // Layer 2 — depends on layer 1
    &[
        "rok-acl",
        "rok-audit",
        "rok-auth",
        "rok-config",
        "rok-events",
        "rok-orm",
        "rok-orm-factory",
        "rok-queue",
        "rok-search",
        "rok-storage",
        "rok-validate",
    ],
    // Layer 3 — depends on layer 2
    &[
        "rok-auth-basic",
        "rok-auth-session",
        "rok-auth-social",
        "rok-bouncer",
        "rok-media",
        "rok-notification",
        "rok-schedule",
    ],
    // Layer 4
    &["rok-error", "rok-tui"],
    // Layer 5 — CLI
    &["rok-cli"],
];

fn run_gate(name: &str, cmd: &mut Command) -> bool {
    print!("  {} ... ", name);
    if std::io::Write::flush(&mut std::io::stdout()).is_err() {
        // ignore
    }
    let output = cmd.output();
    match output {
        Ok(out) if out.status.success() => {
            println!("{}", console::style("PASS").green());
            true
        }
        Ok(out) => {
            println!("{}", console::style("FAIL").red());
            let stderr = String::from_utf8_lossy(&out.stderr);
            if !stderr.is_empty() {
                println!("{}", stderr);
            }
            false
        }
        Err(e) => {
            println!("{}  ({})", console::style("FAIL").red(), e);
            false
        }
    }
}

fn run_gates() -> bool {
    println!("{}", console::style("Running acceptance gates...").bold());

    let fmt_ok = run_gate(
        "fmt",
        Command::new("cargo").args(["fmt", "--all", "--", "--check"]),
    );
    let clippy_ok = run_gate(
        "clippy",
        Command::new("cargo").args(["clippy", "--workspace", "--", "-D", "warnings"]),
    );
    let test_ok = run_gate("test", Command::new("cargo").args(["test", "--workspace"]));
    let doc_ok = run_gate(
        "doc",
        Command::new("cargo").args(["doc", "--workspace", "--no-deps"]),
    );

    let all_pass = fmt_ok && clippy_ok && test_ok && doc_ok;

    if all_pass {
        println!("  {} All gates passed", console::style("").green());
    } else {
        println!(
            "  {} Some gates failed — aborting",
            console::style("").red()
        );
    }

    all_pass
}

fn clean_tree() -> bool {
    let output = Command::new("git").args(["status", "--porcelain"]).output();
    match output {
        Ok(out) => {
            let stdout = String::from_utf8_lossy(&out.stdout);
            if stdout.trim().is_empty() {
                true
            } else {
                println!(
                    "  {} Working tree has uncommitted changes:",
                    console::style("").yellow()
                );
                for line in stdout.lines() {
                    println!("    {}", line);
                }
                false
            }
        }
        Err(_) => {
            println!(
                "  {} Could not check git status",
                console::style("").yellow()
            );
            true // continue anyway
        }
    }
}

fn publish_crate(name: &str, dry_run: bool) -> bool {
    if dry_run {
        println!(
            "  {}  {} (dry run — would publish)",
            console::style("~").yellow(),
            name
        );
        return true;
    }

    print!("  Publishing {} ... ", name);
    let _ = std::io::Write::flush(&mut std::io::stdout());

    let mut cmd = Command::new("cargo");
    cmd.args(["publish", "-p", name]);

    let output = cmd.output();
    match output {
        Ok(out) if out.status.success() => {
            println!("{}", console::style("OK").green());
            // Sleep 60s for rate limit
            println!("    Waiting 60s for crates.io rate limit...");
            std::thread::sleep(Duration::from_secs(60));
            true
        }
        Ok(out) => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            // Check for 429 rate limit
            if stderr.contains("429") || stderr.contains("try again after") {
                // Extract UTC timestamp from error
                if let Some(ts_start) = stderr.find("after ") {
                    let rest = &stderr[ts_start + 6..];
                    if let Some(ts_end) = rest.find(['.', '\n', '\r']) {
                        let timestamp = &rest[..ts_end];
                        println!("    Rate limited — waiting until {}", timestamp);
                        // Wait a generous 90s
                        std::thread::sleep(Duration::from_secs(90));
                        // Retry once
                        let retry = Command::new("cargo").args(["publish", "-p", name]).output();
                        match retry {
                            Ok(r) if r.status.success() => {
                                println!("{}  {}  (retry OK)", console::style("").green(), name);
                                std::thread::sleep(Duration::from_secs(60));
                                return true;
                            }
                            _ => {
                                println!("{}  {}  (retry failed)", console::style("").red(), name);
                                return false;
                            }
                        }
                    }
                }
            }
            println!("{}", console::style("FAIL").red());
            println!("{}", stderr);
            false
        }
        Err(e) => {
            println!("{}  ({})", console::style("FAIL").red(), e);
            false
        }
    }
}

fn create_git_tag(name: &str, version: &str, dry_run: bool) -> bool {
    let tag = format!("{}-v{}", name, version);
    if dry_run {
        println!(
            "  {}  git tag {} (dry run)",
            console::style("~").yellow(),
            tag
        );
        return true;
    }

    let output = Command::new("git").args(["tag", &tag]).output();

    match output {
        Ok(out) if out.status.success() => {
            println!(
                "  {}  Created git tag: {}",
                console::style("").green(),
                tag
            );
            true
        }
        Ok(out) => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            if stderr.contains("already exists") {
                println!(
                    "  {}  Tag {} already exists",
                    console::style("").yellow(),
                    tag
                );
                true
            } else {
                println!(
                    "  {}  Failed to create tag: {}",
                    console::style("").red(),
                    stderr.trim()
                );
                false
            }
        }
        Err(e) => {
            println!("  {}  {}", console::style("").red(), e);
            false
        }
    }
}

fn read_crate_version(name: &str) -> Option<String> {
    let path = Path::new("crates").join(name).join("Cargo.toml");
    if !path.exists() {
        // maybe it's the root crate
        if name == "rok-cli" || name == "rok-error" || name == "rok-tui" || name == "rok-studio" {
            let alt = Path::new("crates").join(name).join("Cargo.toml");
            if alt.exists() {
                return read_version_from_file(&alt);
            }
        }
        return None;
    }
    read_version_from_file(&path)
}

fn read_version_from_file(path: &Path) -> Option<String> {
    let content = std::fs::read_to_string(path).ok()?;
    for line in content.lines() {
        if let Some(ver) = line.strip_prefix("version = \"") {
            if let Some(end) = ver.find('"') {
                return Some(ver[..end].to_string());
            }
        }
    }
    None
}

pub fn run(dry_run: bool, specific_crate: Option<&str>) {
    println!(
        "{}",
        console::style("╔══════════════════════════════════════╗").bold()
    );
    println!(
        "{}",
        console::style("║      rok publish pipeline           ║").bold()
    );
    println!(
        "{}",
        console::style("╚══════════════════════════════════════╝").bold()
    );
    println!();

    // Step 1: Check clean working tree
    if !clean_tree() {
        eprintln!(
            "{} Working tree is not clean. Commit or stash before publishing.",
            console::style("error:").red().bold()
        );
        std::process::exit(1);
    }

    // Step 2: Run gates
    if !run_gates() {
        eprintln!(
            "{} Gates failed. Fix issues then retry.",
            console::style("error:").red().bold()
        );
        std::process::exit(1);
    }

    println!();

    if dry_run {
        println!(
            "{}",
            console::style("Dry-run mode — no crates will be published").yellow()
        );
        println!();
    }

    // Step 3: Publish crates in dependency order
    let mut published = 0u32;
    let mut failed = 0u32;

    for layer in PUBLISH_ORDER {
        for &crate_name in *layer {
            if let Some(specific) = specific_crate {
                if crate_name != specific {
                    continue;
                }
            }

            println!("  {}", console::style(crate_name).bold());

            if let Some(version) = read_crate_version(crate_name) {
                println!("    version: {}", version);
                if publish_crate(crate_name, dry_run) {
                    if !dry_run {
                        create_git_tag(crate_name, &version, dry_run);
                    }
                    published += 1;
                } else {
                    failed += 1;
                }
            } else {
                println!(
                    "    {} Could not find Cargo.toml for {}",
                    console::style("").yellow(),
                    crate_name
                );
                failed += 1;
            }
        }
    }

    println!();
    if dry_run {
        println!(
            "{} Dry run complete — {} crates ready to publish",
            console::style("Summary:").bold(),
            published
        );
    } else if failed == 0 {
        println!(
            "{} All {} crates published successfully!",
            console::style("").green().bold(),
            published
        );
    } else {
        println!(
            "{} Published {}, {} failed",
            console::style("").yellow().bold(),
            published,
            failed
        );
    }
}