rok-cli 0.3.6

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

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

    // Validate bump type
    match bump {
        "patch" | "minor" | "major" => {}
        _ => {
            eprintln!(
                "{} Invalid bump type '{}'. Use patch, minor, or major.",
                console::style("error:").red().bold(),
                bump
            );
            std::process::exit(1);
        }
    }

    // Read current version from root Cargo.toml
    let root_toml = Path::new("Cargo.toml");
    let content = std::fs::read_to_string(root_toml).expect("Cannot read Cargo.toml");
    let current_version = content
        .lines()
        .find(|l| l.trim().starts_with("version = "))
        .and_then(|l| l.split('"').nth(1))
        .map(|v| v.to_string())
        .expect("Cannot find version in Cargo.toml");

    let new_version = bump_version(&current_version, bump);
    println!(
        "  {} {}{}",
        console::style("Version:").bold(),
        current_version,
        console::style(&new_version).cyan().bold()
    );
    println!();

    if let Some(name) = specific_crate {
        bump_crate_version(name, &current_version, &new_version);
    } else {
        bump_workspace_version(&current_version, &new_version);
    }

    println!();

    // Git commit
    let commit_msg = format!("bump version to {}", new_version);
    println!(
        "  {} git commit -m \"{}\"",
        console::style("~").yellow(),
        &commit_msg
    );
    let commit = Command::new("git")
        .args(["commit", "-am", &commit_msg])
        .output();
    match commit {
        Ok(out) if out.status.success() => {
            println!("  {} Committed", console::style("").green());
        }
        Ok(out) => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            if stderr.contains("nothing to commit") {
                println!(
                    "  {} Nothing to commit (already up to date)",
                    console::style("").yellow()
                );
            } else {
                println!(
                    "  {} Commit failed: {}",
                    console::style("").red(),
                    stderr.trim()
                );
                return;
            }
        }
        Err(e) => {
            println!("  {} {}", console::style("").red(), e);
            return;
        }
    }

    // Create git tag
    let tag = format!("v{}", new_version);
    println!("  {} git tag {}", console::style("~").yellow(), &tag);
    let tag_result = Command::new("git").args(["tag", &tag]).output();
    match tag_result {
        Ok(out) if out.status.success() => {
            println!("  {} Created tag: {}", console::style("").green(), &tag);
        }
        _ => {
            println!(
                "  {} Tag {} may already exist",
                console::style("").yellow(),
                &tag
            );
        }
    }

    // Push
    println!("  {} git push --tags", console::style("~").yellow());
    let push = Command::new("git").args(["push", "--tags"]).output();
    match push {
        Ok(out) if out.status.success() => {
            println!("  {} Pushed tags", console::style("").green());
        }
        Ok(out) => {
            println!(
                "  {} Push failed (may need remote setup): {}",
                console::style("").yellow(),
                String::from_utf8_lossy(&out.stderr)
                    .lines()
                    .next()
                    .unwrap_or("")
            );
        }
        Err(_) => {
            println!(
                "  {} Git not available — tags created locally",
                console::style("").yellow()
            );
        }
    }

    // Create GitHub Release
    println!(
        "  {} gh release create {} --generate-notes",
        console::style("~").yellow(),
        &tag
    );
    let gh = Command::new("gh")
        .args(["release", "create", &tag, "--generate-notes"])
        .output();
    match gh {
        Ok(out) if out.status.success() => {
            println!(
                "  {} Created GitHub Release: {}",
                console::style("").green(),
                &tag
            );
        }
        Ok(out) => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            if stderr.contains("not authenticated") || stderr.contains("not found") {
                println!("  {} GitHub CLI not configured — tag created locally. Create release manually:", console::style("").yellow());
                println!("    gh release create {} --generate-notes", &tag);
            } else {
                println!("  {}", console::style("").yellow());
            }
        }
        Err(_) => {
            println!(
                "  {} gh CLI not found — install: https://cli.github.com",
                console::style("").yellow()
            );
        }
    }

    println!();
    println!(
        "  {} Release v{} complete!",
        console::style("").green().bold(),
        &new_version
    );

    if !skip_publish {
        println!();
        println!("  Running publish step...");
        super::publish::run(false, specific_crate);
    }
}

fn bump_version(version: &str, bump: &str) -> String {
    let parts: Vec<&str> = version.split('.').collect();
    let major: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
    let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
    let patch: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);

    match bump {
        "major" => format!("{}.{}.{}", major + 1, 0, 0),
        "minor" => format!("{}.{}.{}", major, minor + 1, 0),
        "patch" => format!("{}.{}.{}", major, minor, patch + 1),
        _ => version.to_string(),
    }
}

fn bump_workspace_version(old: &str, new: &str) {
    // Update root Cargo.toml
    bump_file_version("Cargo.toml", old, new);

    // Update all crate Cargo.toml files
    let crate_dirs = [
        "crates/rok-cli",
        "crates/rok-tui",
        "crates/rok-studio",
        "crates/rok-auth",
        "crates/rok-auth-macros",
        "crates/rok-auth-basic",
        "crates/rok-auth-session",
        "crates/rok-auth-social",
        "crates/rok-orm",
        "crates/rok-orm-core",
        "crates/rok-orm-macros",
        "crates/rok-orm-migrate",
        "crates/rok-orm-factory",
        "crates/rok-validate",
        "crates/rok-validate-macros",
        "crates/rok-config",
        "crates/rok-config-macros",
        "crates/rok-mail",
        "crates/rok-cache",
        "crates/rok-bouncer",
        "crates/rok-hash",
        "crates/rok-encrypt",
        "crates/rok-lock",
        "crates/rok-cors",
        "crates/rok-shield",
        "crates/rok-rate-limit",
        "crates/rok-events",
        "crates/rok-events-macros",
        "crates/rok-testing",
        "crates/rok-ids",
        "crates/rok-queue-macros",
        "crates/rok-queue",
        "crates/rok-schedule",
        "crates/rok-storage",
        "crates/rok-notification",
        "crates/rok-websocket",
        "crates/rok-telemetry",
        "crates/rok-search-macros",
        "crates/rok-search",
        "crates/rok-feature",
        "crates/rok-i18n",
        "crates/rok-problem",
        "crates/rok-media",
        "crates/rok-audit",
        "crates/rok-audit-macros",
        "crates/rok-acl",
        "crates/rok-acl-macros",
        "crates/rok-health",
        "crates/rok-router",
        "crates/rok-error",
    ];

    for dir in &crate_dirs {
        let path = format!("{}/Cargo.toml", dir);
        if Path::new(&path).exists() {
            bump_file_version(&path, old, new);
        }
    }
}

fn bump_crate_version(name: &str, old: &str, new: &str) {
    let path = format!("crates/{}/Cargo.toml", name);
    if Path::new(&path).exists() {
        bump_file_version(&path, old, new);
    }
    // Also update inter-crate deps in other Cargo.toml files
    let crate_dirs = [
        "Cargo.toml",
        "crates/rok-cli/Cargo.toml",
        "crates/rok-tui/Cargo.toml",
    ];
    for file in &crate_dirs {
        if Path::new(file).exists() {
            update_inter_crate_dep(file, name, new);
        }
    }
}

fn bump_file_version(path: &str, old: &str, new: &str) {
    let content = std::fs::read_to_string(path).unwrap_or_else(|_| panic!("Cannot read {}", path));
    let old_line = format!("version = \"{}\"", old);
    let new_line = format!("version = \"{}\"", new);
    if content.contains(&old_line) {
        let updated = content.replace(&old_line, &new_line);
        std::fs::write(path, updated).unwrap_or_else(|_| panic!("Cannot write {}", path));
        println!("  {}  Updated {}", console::style("~").cyan(), path);
    }
}

fn update_inter_crate_dep(path: &str, crate_name: &str, new_version: &str) {
    let content = std::fs::read_to_string(path).unwrap_or_else(|_| panic!("Cannot read {}", path));
    let dep_pattern = format!("{crate_name} = {{ path = \"");
    if content.contains(&dep_pattern) {
        // Find the version = "x.y" or version = "x.y.z" part after path
        let mut updated = content.clone();
        let mut search_from = 0;
        while let Some(start) = updated[search_from..].find(&dep_pattern) {
            let abs_start = search_from + start;
            if let Some(ver_start) = updated[abs_start..].find("version = \"") {
                let abs_ver_start = abs_start + ver_start + 10; // after 'version = "'
                if let Some(ver_end) = updated[abs_ver_start..].find('"') {
                    let abs_ver_end = abs_ver_start + ver_end;
                    let old_ver = &updated[abs_ver_start..abs_ver_end];
                    // Only replace x.y or x.y.z versions
                    if old_ver.contains('.') {
                        let new_short = if new_version.matches('.').count() >= 2 {
                            // Use major.minor for path deps
                            let parts: Vec<&str> = new_version.split('.').collect();
                            format!("{}.{}", parts[0], parts[1])
                        } else {
                            new_version.to_string()
                        };
                        updated.replace_range(abs_ver_start..abs_ver_end, &new_short);
                        search_from = abs_ver_start + new_short.len();
                        println!(
                            "  {}  Updated dep {} in {}",
                            console::style("~").cyan(),
                            crate_name,
                            path
                        );
                        continue;
                    }
                }
            }
            search_from = abs_start + 1;
        }
        if updated != content {
            std::fs::write(path, updated).unwrap_or_else(|_| panic!("Cannot write {}", path));
        }
    }
}