bver 0.1.5

A bump-version tool for multi-language projects
Documentation
use std::fs;
use std::path::Path;
use std::process::Command;

use crate::finders::find_repo_root;
use crate::schema::{Action, GitConfig, RunPreCommit};

/// Detected pre-commit tool type
enum PreCommitTool {
    Prek,
    PreCommit,
}

/// Check if a command is available
fn command_available(cmd: &str) -> bool {
    Command::new(cmd)
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// Detect which pre-commit tool is available and configured
fn detect_pre_commit_tool() -> Option<PreCommitTool> {
    let repo_root = find_repo_root()?;
    let hook_path = repo_root.join(".git/hooks/pre-commit");

    if !hook_path.exists() {
        return None;
    }

    // Check if the hook is a prek hook
    if let Ok(content) = fs::read_to_string(&hook_path)
        && content.contains("File generated by prek")
        && command_available("prek")
    {
        return Some(PreCommitTool::Prek);
    }

    // Fall back to pre-commit if available
    if command_available("pre-commit") {
        return Some(PreCommitTool::PreCommit);
    }

    None
}

/// Run pre-commit hooks based on config setting
pub fn maybe_run_pre_commit(setting: RunPreCommit) -> Result<(), String> {
    match setting {
        RunPreCommit::Disabled => Ok(()),
        RunPreCommit::Enabled => run_pre_commit(true),
        RunPreCommit::WhenPresent => run_pre_commit(false),
    }
}

fn run_pre_commit(required: bool) -> Result<(), String> {
    let tool = match detect_pre_commit_tool() {
        Some(t) => t,
        None => {
            if required {
                return Err(
                    "pre-commit/prek is not installed but run-pre-commit is enabled".to_string(),
                );
            }
            return Ok(());
        }
    };

    let (cmd, name) = match tool {
        PreCommitTool::Prek => ("prek", "prek"),
        PreCommitTool::PreCommit => ("pre-commit", "pre-commit"),
    };

    println!("Running {} hooks...", name);

    let status = Command::new(cmd)
        .args(["run", "--all-files"])
        .status()
        .map_err(|e| format!("Failed to run {}: {}", name, e))?;

    if status.success() {
        println!("{} hooks passed.", name);
        return Ok(());
    }

    // Failed, run it a second time (it may have auto-fixed files)
    println!("{} hooks failed, running again...", name);

    let status = Command::new(cmd)
        .args(["run", "--all-files"])
        .status()
        .map_err(|e| format!("Failed to run {}: {}", name, e))?;

    if status.success() {
        println!("{} hooks passed on second run.", name);
        Ok(())
    } else {
        Err(format!("{} hooks failed twice, aborting bump", name))
    }
}

/// Run a git command and return the result
fn git(args: &[&str]) -> Result<(), String> {
    println!("Running: git {}", args.join(" "));

    let output = Command::new("git")
        .args(args)
        .output()
        .map_err(|e| format!("Failed to run git: {e}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("git {} failed: {}", args[0], stderr.trim()));
    }

    Ok(())
}

/// Apply template substitutions for version strings
fn apply_template(template: &str, current_version: &str, new_version: &str) -> String {
    template
        .replace("{current-version}", current_version)
        .replace("{new-version}", new_version)
}

/// Run git operations based on config setting
pub fn run_git_actions(
    git_config: &GitConfig,
    current_version: &str,
    new_version: &str,
    force: bool,
    changed_files: &[&Path],
) -> Result<(), String> {
    let tag_name = apply_template(&git_config.tag_template, current_version, new_version);
    let commit_msg = apply_template(&git_config.commit_template, current_version, new_version);
    let branch_name = apply_template(&git_config.branch_template, current_version, new_version);

    if git_config.has(Action::Branch) {
        git_checkout_new_branch(&branch_name)?;
    }
    if git_config.has(Action::AddAll) {
        git_add_all()?;
    } else if git_config.has(Action::Commit) {
        git_add_files(changed_files)?;
    }
    if git_config.has(Action::Commit) {
        git_commit(&commit_msg)?;
    }
    if git_config.has(Action::Tag) {
        git_tag(&tag_name, new_version, force)?;
    }
    if git_config.has(Action::Push) {
        let set_upstream = git_config.has(Action::Branch);
        git_push(force, set_upstream, &branch_name)?;
        if git_config.has(Action::Tag) {
            git_push_tag(&tag_name, force)?;
        }
    }
    if git_config.has(Action::Pr) {
        gh_pr_create(&commit_msg)?;
    }

    Ok(())
}

fn git_add_all() -> Result<(), String> {
    git(&["add", "--all"])
}

fn git_add_files(paths: &[&Path]) -> Result<(), String> {
    for path in paths {
        let path_str = path.to_str().ok_or_else(|| format!("Invalid path: {:?}", path))?;
        git(&["add", path_str])?;
    }
    Ok(())
}

fn git_commit(msg: &str) -> Result<(), String> {
    git(&["commit", "-m", msg])
}

fn git_tag(tag_name: &str, version: &str, force: bool) -> Result<(), String> {
    let msg = format!("Release {}", version);
    if force {
        git(&["tag", "-a", tag_name, "-m", &msg, "-f"])
    } else {
        git(&["tag", "-a", tag_name, "-m", &msg])
    }
}

fn git_push(force: bool, set_upstream: bool, branch: &str) -> Result<(), String> {
    match (force, set_upstream) {
        (true, true) => git(&["push", "-u", "origin", branch, "--force"]),
        (true, false) => git(&["push", "--force"]),
        (false, true) => git(&["push", "-u", "origin", branch]),
        (false, false) => git(&["push"]),
    }
}

fn git_push_tag(tag_name: &str, force: bool) -> Result<(), String> {
    if force {
        git(&["push", "origin", tag_name, "--force"])
    } else {
        git(&["push", "origin", tag_name])
    }
}

fn git_checkout_new_branch(name: &str) -> Result<(), String> {
    git(&["checkout", "-b", name])
}

fn gh_pr_create(title: &str) -> Result<(), String> {
    println!("Running: gh pr create --title {:?} --body \"\"", title);

    let output = Command::new("gh")
        .args(["pr", "create", "--title", title, "--body", ""])
        .output()
        .map_err(|e| format!("Failed to run gh: {e}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("gh pr create failed: {}", stderr.trim()));
    }

    Ok(())
}