git-ca 0.2.5

git plugin that drafts commit messages using GitHub Copilot
use std::path::PathBuf;
use std::process::Command;

use crate::error::{Error, Result};
use crate::pr_msg::PullRequestMessage;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BaseBranch {
    pub pr_base: String,
    pub compare_ref: String,
}

impl BaseBranch {
    pub fn explicit(name: String) -> Self {
        Self {
            pr_base: name.clone(),
            compare_ref: name,
        }
    }
}

pub fn default_base() -> BaseBranch {
    let out = Command::new("git")
        .args([
            "symbolic-ref",
            "--quiet",
            "--short",
            "refs/remotes/origin/HEAD",
        ])
        .output();
    let Ok(out) = out else {
        return fallback_base();
    };
    if !out.status.success() {
        return fallback_base();
    }
    base_from_origin_head(String::from_utf8_lossy(&out.stdout).trim())
}

fn fallback_base() -> BaseBranch {
    BaseBranch::explicit("main".to_string())
}

fn base_from_origin_head(branch: &str) -> BaseBranch {
    if let Some(pr_base) = branch.strip_prefix("origin/") {
        return BaseBranch {
            pr_base: pr_base.to_string(),
            compare_ref: branch.to_string(),
        };
    }
    BaseBranch::explicit(branch.to_string())
}

pub fn merge_base(base: &str) -> Result<String> {
    let out = super::run_git_capture(&["merge-base", base, "HEAD"])?;
    let hash = out.trim();
    if hash.is_empty() {
        return Err(Error::Config(format!(
            "unable to resolve merge-base for `{base}`"
        )));
    }
    Ok(hash.to_string())
}

pub fn branch_diff(base: &str) -> Result<String> {
    let args = diff_args(base);
    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
    let out = super::run_git_capture(&refs)?;
    non_empty_source(out, "branch has no changes against base")
}

pub fn commit_log(base: &str) -> Result<String> {
    let args = commit_log_args(base);
    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
    let out = super::run_git_capture(&refs)?;
    non_empty_source(out, "branch has no commits against base")
}

pub fn ensure_gh_available() -> Result<()> {
    let status = Command::new("gh").arg("--version").status()?;
    if status.success() {
        return Ok(());
    }
    Err(Error::Git(
        "gh --version".to_string(),
        status.code().unwrap_or(1),
    ))
}

pub fn edit_message(draft: &PullRequestMessage) -> Result<PullRequestMessage> {
    let path = message_path("PULL_REQUEST_EDITMSG")?;
    std::fs::write(&path, format!("{}\n\n{}\n", draft.title, draft.body))?;

    let editor = super::run_git_capture(&["var", "GIT_EDITOR"])?
        .trim()
        .to_string();
    if editor.is_empty() {
        return Err(Error::Config("GIT_EDITOR is empty".to_string()));
    }

    let status = Command::new("sh")
        .arg("-c")
        .arg(format!("{} \"$1\"", editor))
        .arg("git-ca-editor")
        .arg(&path)
        .status()?;
    if !status.success() {
        return Err(Error::Git("editor".to_string(), status.code().unwrap_or(1)));
    }

    let edited = std::fs::read_to_string(&path)?;
    parse_editor_message(&edited)
}

pub fn create_pull_request(base: &str, title: &str, body: &str) -> Result<()> {
    let path = message_path("PULL_REQUEST_BODY")?;
    std::fs::write(&path, body)?;
    let body_file = path
        .to_str()
        .ok_or_else(|| Error::Config("PULL_REQUEST_BODY path is not UTF-8".into()))?;
    let args = gh_pr_create_args(base, title, body_file);
    let status = Command::new("gh").args(&args).status()?;
    if !status.success() {
        return Err(Error::Git(
            "gh pr create".to_string(),
            status.code().unwrap_or(1),
        ));
    }
    Ok(())
}

fn message_path(name: &str) -> Result<PathBuf> {
    let git_dir = super::run_git_capture(&["rev-parse", "--git-dir"])?
        .trim()
        .to_string();
    let mut path = PathBuf::from(git_dir);
    path.push(name);
    Ok(path)
}

fn non_empty_source(out: String, message: &str) -> Result<String> {
    if out.trim().is_empty() {
        return Err(Error::Config(message.to_string()));
    }
    Ok(out)
}

fn parse_editor_message(text: &str) -> Result<PullRequestMessage> {
    let trimmed = text.trim();
    let (title, body) = trimmed
        .split_once("\n\n")
        .ok_or_else(|| Error::Config("PR body cannot be empty".to_string()))?;
    let title = title.trim().to_string();
    let body = body.trim().to_string();
    if title.is_empty() {
        return Err(Error::Config("PR title cannot be empty".to_string()));
    }
    if body.is_empty() {
        return Err(Error::Config("PR body cannot be empty".to_string()));
    }
    Ok(PullRequestMessage { title, body })
}

pub(crate) fn diff_args(base: &str) -> Vec<String> {
    vec![
        "diff".to_string(),
        "--no-color".to_string(),
        "-U3".to_string(),
        format!("{base}...HEAD"),
    ]
}

pub(crate) fn commit_log_args(base: &str) -> Vec<String> {
    vec![
        "log".to_string(),
        "--no-merges".to_string(),
        "--format=%s%n%n%b".to_string(),
        format!("{base}..HEAD"),
    ]
}

pub(crate) fn gh_pr_create_args(base: &str, title: &str, body_file: &str) -> Vec<String> {
    vec![
        "pr".to_string(),
        "create".to_string(),
        "--base".to_string(),
        base.to_string(),
        "--title".to_string(),
        title.to_string(),
        "--body-file".to_string(),
        body_file.to_string(),
    ]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn diff_args_compare_base_to_head() {
        assert_eq!(
            diff_args("main"),
            ["diff", "--no-color", "-U3", "main...HEAD"]
        );
    }

    #[test]
    fn commit_log_args_compare_base_to_head() {
        assert_eq!(
            commit_log_args("main"),
            ["log", "--no-merges", "--format=%s%n%n%b", "main..HEAD"]
        );
    }

    #[test]
    fn gh_pr_create_args_include_title_and_body_file() {
        assert_eq!(
            gh_pr_create_args("main", "Add PR drafts", ".git/PULL_REQUEST_BODY"),
            [
                "pr",
                "create",
                "--base",
                "main",
                "--title",
                "Add PR drafts",
                "--body-file",
                ".git/PULL_REQUEST_BODY"
            ]
        );
    }

    #[test]
    fn default_origin_head_uses_remote_ref_for_comparison() {
        assert_eq!(
            base_from_origin_head("origin/main"),
            BaseBranch {
                pr_base: "main".to_string(),
                compare_ref: "origin/main".to_string(),
            }
        );
    }

    #[test]
    fn non_origin_default_base_uses_same_name_for_pr_and_comparison() {
        assert_eq!(
            base_from_origin_head("upstream/trunk"),
            BaseBranch::explicit("upstream/trunk".to_string())
        );
    }

    #[test]
    fn parse_editor_message_splits_title_and_body() {
        let msg = parse_editor_message("Add PR drafts\n\n## Summary\n- add flow\n").unwrap();

        assert_eq!(msg.title, "Add PR drafts");
        assert_eq!(msg.body, "## Summary\n- add flow");
    }
}