git-ca 0.2.5

git plugin that drafts commit messages using GitHub Copilot
use serde::Deserialize;

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

pub mod prompt {
    use crate::cli::PrSource;
    use crate::commit_msg::prompt::ChatMessage;

    /// Anything past this gets truncated with a marker. Keep parity with commit
    /// drafting so PR generation handles large branches predictably.
    const SOURCE_CHAR_LIMIT: usize = 32_000;

    const SYSTEM_PROMPT_PREFIX: &str = "\
You are a senior engineer writing GitHub pull request text.

Respond with ONLY compact JSON — no prose before or after, no code fences, no
quoting outside JSON.

Required JSON shape:
{
  \"title\": \"short imperative PR title\",
  \"body\": \"Markdown PR body\"
}

";

    const PREDEFINED_RULES: &str = "\
- title: imperative mood, no trailing period, ≤ 72 chars
- body: Markdown. Treat it as a mini architecture note that helps reviewers
  answer: \"What am I reviewing, why does it matter, and where should I focus?\"
- a PR message is different from a commit message: a commit explains one
  change; a PR explains the whole story
- include these sections, omitting only sections that truly do not apply:
  ## Summary
  ## Context
  ## Changes
  ## Trade-offs / Risks
  ## Testing
  ## Screenshots / Demo
  ## References
- Summary: explain what this PR changes at a high level
- Context: explain why the PR exists and what problem it solves
- Changes: describe key implementation points, not every file changed
- Trade-offs / Risks: call out limitations, migration notes, review risks, and
  behavior that old clients/users keep
- Testing: list only verification shown by the input; if none is visible, say
  \"Not run (not shown in input)\"
- Screenshots / Demo: include for UI-visible changes when the input supports it
- References: include only issues, incidents, docs, or links present in input
- describe HOW when implementation is non-trivial, reviewers need guidance,
  there are architectural decisions/trade-offs, the diff is large, the PR
  touches multiple layers, or future development is affected
- Avoid HOW when it only repeats the file diff
- bad HOW: \"Changed `order.ts`; updated `api.ts`; added `utils.ts`\"
- good HOW: \"Centralizes retry handling in the request layer so individual API
  calls do not need their own retry logic\"
- do not invent tickets, reviewers, labels, or test results not present in the input
";

    pub fn build(
        source: PrSource,
        base: &str,
        text: &str,
        custom_rules: Option<&str>,
    ) -> Vec<ChatMessage> {
        let source_label = match source {
            PrSource::Diff => "Branch diff",
            PrSource::Commits => "Commit log",
        };
        vec![
            ChatMessage::system(system_prompt(custom_rules)),
            ChatMessage::user(format!(
                "Base branch: {base}\nSource: {source_label}\n\n```text\n{}\n```",
                truncate(text)
            )),
        ]
    }

    fn system_prompt(custom_rules: Option<&str>) -> String {
        let rules = custom_rules.unwrap_or(PREDEFINED_RULES);
        format!("{SYSTEM_PROMPT_PREFIX}Rules:\n{rules}")
    }

    fn truncate(text: &str) -> String {
        if text.len() <= SOURCE_CHAR_LIMIT {
            return text.to_string();
        }
        let mut end = SOURCE_CHAR_LIMIT;
        while !text.is_char_boundary(end) {
            end -= 1;
        }
        let mut out = String::with_capacity(end + 64);
        out.push_str(&text[..end]);
        out.push_str("\n\n# ... PR source truncated to ");
        out.push_str(&end.to_string());
        out.push_str(" bytes ...\n");
        out
    }

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

        #[test]
        fn build_mentions_commit_log_source() {
            let messages = build(PrSource::Commits, "main", "feat: add PR flow", None);

            assert!(messages[1].content.contains("Base branch: main"));
            assert!(messages[1].content.contains("Source: Commit log"));
            assert!(messages[1].content.contains("feat: add PR flow"));
        }

        #[test]
        fn build_uses_custom_rules_in_predefined_system_prompt() {
            let messages = build(
                PrSource::Diff,
                "main",
                "diff --git a/x b/x",
                Some("- custom PR rule\n"),
            );

            assert!(messages[0].content.contains("Required JSON shape"));
            assert!(messages[0].content.contains("Rules:\n- custom PR rule\n"));
            assert!(!messages[0].content.contains("- title: imperative mood"));
            assert!(messages[1].content.contains("Source: Branch diff"));
        }

        #[test]
        fn build_uses_predefined_rules_by_default() {
            let messages = build(PrSource::Diff, "main", "diff --git a/x b/x", None);

            assert!(messages[0]
                .content
                .contains("Rules:\n- title: imperative mood"));
            assert!(messages[0].content.contains("- include these sections"));
        }

        #[test]
        fn system_prompt_requests_review_story_sections() {
            let messages = build(PrSource::Diff, "main", "diff --git a/x b/x", None);

            let system = &messages[0].content;
            for section in [
                "## Summary",
                "## Context",
                "## Changes",
                "## Trade-offs / Risks",
                "## Testing",
                "## Screenshots / Demo",
                "## References",
            ] {
                assert!(system.contains(section), "missing section: {section}");
            }
            assert!(system.contains("mini architecture note"));
            assert!(system.contains("Avoid HOW when it only repeats the file diff"));
        }
    }
}

#[derive(Debug, PartialEq, Eq)]
pub struct PullRequestMessage {
    pub title: String,
    pub body: String,
}

#[derive(Debug, Deserialize)]
struct RawPullRequestMessage {
    title: String,
    body: String,
}

pub fn parse_json(raw: &str) -> Result<PullRequestMessage> {
    let raw = crate::commit_msg::strip_code_fences(raw);
    let msg: RawPullRequestMessage = serde_json::from_str(&raw)?;
    let title = msg.title.trim().to_string();
    let body = msg.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 })
}

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

    #[test]
    fn parses_pr_message_json() {
        let msg =
            parse_json(r###"{"title":"Add PR drafts","body":"## Summary\n- adds PR flow"}"###)
                .unwrap();

        assert_eq!(
            msg,
            PullRequestMessage {
                title: "Add PR drafts".to_string(),
                body: "## Summary\n- adds PR flow".to_string(),
            }
        );
    }

    #[test]
    fn parses_fenced_pr_message_json() {
        let msg = parse_json(
            "```json\n{\"title\":\"Add PR drafts\",\"body\":\"## Summary\\n- adds PR flow\"}\n```",
        )
        .unwrap();

        assert_eq!(msg.title, "Add PR drafts");
        assert!(msg.body.contains("adds PR flow"));
    }

    #[test]
    fn rejects_empty_pr_title() {
        let err = parse_json(r#"{"title":" ","body":"body"}"#).unwrap_err();

        assert!(matches!(err, Error::Config(msg) if msg == "PR title cannot be empty"));
    }
}