commitbot 0.6.0

A CLI assistant that generates commit and PR messages from your diffs using LLMs.
use std::collections::BTreeMap;

use crate::git::{PrItem, PrSummaryMode};
use crate::llm::prompts;
use crate::{FileCategory, FileChange};

pub struct PromptPair {
    pub system: String,
    pub user: String,
}

pub fn file_summary_prompt(
    branch: &str,
    file: &FileChange,
    file_index: usize,
    total_files: usize,
    ticket_summary: Option<&str>,
) -> PromptPair {
    let mut system = prompts::FILE_SUMMARY.to_owned();
    if let Some(ts) = ticket_summary {
        system.push_str("\nOverall ticket goal: ");
        system.push_str(ts);
    }

    let user = format!(
        "Branch: {branch}\n\
         File {file_num} of {total_files}: {path}\n\
         Category: {category}\n\n\
         Diff:\n\
         ```diff\n{diff}\n```",
        branch = branch,
        file_num = file_index + 1,
        total_files = total_files,
        path = file.path,
        category = file.category.as_str(),
        diff = file.diff
    );

    PromptPair { system, user }
}

pub fn commit_message_prompt(
    branch: &str,
    files: &[FileChange],
    ticket_summary: Option<&str>,
) -> PromptPair {
    let mut system = prompts::SYSTEM_INSTRUCTIONS.to_owned();
    if let Some(ts) = ticket_summary {
        system.push_str("\nOverall ticket goal: ");
        system.push_str(ts);
    }

    let per_file = render_per_file_summaries(files);
    let file_count = files.len();
    let user = format!(
        "Branch: {branch}\n\nFiles Changed: {file_count}\n\nPer-file summaries:\n\n{per_file}",
        branch = branch,
        file_count = file_count + 1,
        per_file = per_file
    );

    PromptPair { system, user }
}

pub fn pr_message_prompt(
    base_branch: &str,
    from_branch: &str,
    mode: PrSummaryMode,
    items: &[PrItem],
    ticket_summary: Option<&str>,
) -> PromptPair {
    let mut system = prompts::PR_INSTRUCTIONS.to_owned();
    if let Some(ts) = ticket_summary {
        system.push_str("\nOverall ticket goal: ");
        system.push_str(ts);
    }

    let mut user = String::new();
    user.push_str(&format!(
        "Base branch: {base}\nFeature branch: {from}\nSummary mode: {mode}\n\n",
        base = base_branch,
        from = from_branch,
        mode = mode.as_str()
    ));

    match mode {
        PrSummaryMode::ByCommits => {
            user.push_str("Commit history (oldest first):\n");
            for item in items {
                let short = item.commit_hash.chars().take(7).collect::<String>();
                let pr_tag = item
                    .pr_number
                    .map(|n| format!(" (PR #{n})"))
                    .unwrap_or_default();
                user.push_str(&format!(
                    "- {short}{pr_tag}: {title}\n",
                    title = item.title.trim()
                ));
                if !item.body.trim().is_empty() {
                    user.push_str("  Body:\n");
                    user.push_str("  ");
                    user.push_str(&item.body.replace('\n', "\n  "));
                    user.push('\n');
                }
            }
        }
        PrSummaryMode::ByPrs => {
            let mut grouped: BTreeMap<u32, Vec<&PrItem>> = BTreeMap::new();
            let mut no_pr: Vec<&PrItem> = Vec::new();

            for item in items {
                if let Some(num) = item.pr_number {
                    grouped.entry(num).or_default().push(item);
                } else {
                    no_pr.push(item);
                }
            }

            user.push_str("Pull requests contributing to this branch (oldest commits first):\n");

            for (num, group) in grouped {
                let short = group[0].commit_hash.chars().take(7).collect::<String>();
                let title = group[0].title.trim();
                user.push_str(&format!("\nPR #{num}: {title} [{short}]\n"));

                if group.len() > 1 {
                    user.push_str("Additional commits in this PR:\n");
                    for item in group.iter().skip(1) {
                        let sh = item.commit_hash.chars().take(7).collect::<String>();
                        user.push_str(&format!("- {sh}: {title}\n", title = item.title.trim()));
                    }
                }
            }

            if !no_pr.is_empty() {
                user.push_str(
                    "\nCommits without associated PR numbers (may be small fixes or direct pushes):\n",
                );
                for item in no_pr {
                    let short = item.commit_hash.chars().take(7).collect::<String>();
                    user.push_str(&format!("- {short}: {title}\n", title = item.title.trim()));
                }
            }
        }
    }

    PromptPair { system, user }
}

fn render_per_file_summaries(files: &[FileChange]) -> String {
    let total_files = files.len();
    let mut out = String::new();
    for (idx, file) in files
        .iter()
        .enumerate()
        .filter(|(_, f)| !matches!(f.category, FileCategory::Ignored))
    {
        out.push_str(&format!(
            "File {file_num} of {total_files}: {path}\nCategory: {category}\nSummary:\n{summary}\n\n",
            file_num = idx + 1,
            total_files = total_files,
            path = file.path,
            category = file.category.as_str(),
            summary = file
                .summary
                .as_deref()
                .unwrap_or("[missing per-file summary]")
        ));
    }
    out
}