autocommit-rs 0.1.2

Tool that generates conventional commits from staged changes and executes the commit in one go.
use anyhow::{Context, Result};
use colored::Colorize;
use serde::Serialize;

use crate::git::diff::{clean_diff, generate_prompt};
use crate::types::{CommitType, MessageStyle};

use super::prompts::{
    FALLBACK_MESSAGE, MAX_DIFF_LENGTH, MAX_TOKENS_LONG, MAX_TOKENS_SHORT, SYSTEM_PROMPT_LONG,
    SYSTEM_PROMPT_SHORT,
};

const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";

#[derive(Serialize)]
struct ChatMessage {
    role: String,
    content: String,
}

#[derive(Serialize)]
struct ChatCompletionRequest {
    model: String,
    messages: Vec<ChatMessage>,
    temperature: f64,
    max_completion_tokens: u32,
}

#[derive(serde::Deserialize)]
struct ChatCompletionResponse {
    choices: Vec<Choice>,
}

#[derive(serde::Deserialize)]
struct Choice {
    message: Option<ChoiceMessage>,
}

#[derive(serde::Deserialize)]
struct ChoiceMessage {
    content: Option<String>,
}

pub async fn generate_commit_message(
    diff: &str,
    commit_type: Option<CommitType>,
    files: &[String],
    branch_name: &str,
    message_style: MessageStyle,
) -> Result<String> {
    let api_key = std::env::var("OPENAI_API_KEY")
        .context("OPENAI_API_KEY environment variable is not set")?;

    if diff.trim().is_empty() {
        eprintln!("{} No diff found", "".red());
        return Ok(FALLBACK_MESSAGE.to_string());
    }

    let cleaned_diff = clean_diff(diff);
    let truncated_diff: String = cleaned_diff.chars().take(MAX_DIFF_LENGTH).collect();

    let system_prompt = match message_style {
        MessageStyle::Long => SYSTEM_PROMPT_LONG,
        MessageStyle::Short => SYSTEM_PROMPT_SHORT,
    };

    let max_tokens = match message_style {
        MessageStyle::Long => MAX_TOKENS_LONG,
        MessageStyle::Short => MAX_TOKENS_SHORT,
    };

    let user_prompt = generate_prompt(&truncated_diff, commit_type, files, branch_name);

    let client = reqwest::Client::new();
    let request = ChatCompletionRequest {
        model: "gpt-5.4-nano".to_string(),
        messages: vec![
            ChatMessage {
                role: "system".to_string(),
                content: system_prompt.to_string(),
            },
            ChatMessage {
                role: "user".to_string(),
                content: user_prompt,
            },
        ],
        temperature: 0.0,
        max_completion_tokens: max_tokens,
    };

    match client
        .post(OPENAI_API_URL)
        .header("Authorization", format!("Bearer {}", api_key))
        .json(&request)
        .send()
        .await
    {
        Ok(response) => {
            if let Ok(completion) = response.json::<ChatCompletionResponse>().await
                && let Some(content) = completion
                    .choices
                    .first()
                    .and_then(|c| c.message.as_ref())
                    .and_then(|m| m.content.as_ref())
                    .map(|c| c.trim().to_string())
                && !content.is_empty()
            {
                return Ok(content);
            }
            Ok(match commit_type {
                Some(t) => format!("{}(scope): update files (fallback)", t.as_str()),
                None => FALLBACK_MESSAGE.to_string(),
            })
        }
        Err(e) => {
            eprintln!("OpenAI API Error: {}", e);
            Ok(match commit_type {
                Some(t) => format!("{}(scope): update files (fallback)", t.as_str()),
                None => FALLBACK_MESSAGE.to_string(),
            })
        }
    }
}