use crate::cli::UI;
use anyhow::{bail, Result};
use std::path::Path;
const DEFAULT_API_URL: &str = "https://api.anthropic.com/v1/messages";
const DEFAULT_MODEL: &str = "claude-sonnet-4-5-20250929";
const MAX_DIFF_BYTES: usize = 32768;
struct AiConfig {
api_key: String,
api_url: String,
model: String,
}
const COMMIT_PROMPT: &str = r#"You are a commit message generator. Given the following git diff of staged changes, generate a conventional commit message.
Format: <type>(<optional scope>): <description>
<optional body>
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Use imperative mood ("add" not "added")
- Keep the first line under 72 characters
- Add a body only if the changes are complex
- Do not include markdown formatting or backticks
- Respond with ONLY the commit message, nothing else
Staged diff:
"#;
fn load_config() -> Result<AiConfig> {
let api_key = std::env::var("SECUREGIT_AI_API_KEY")
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
.map_err(|_| {
anyhow::anyhow!(
"AI commit message requires an API key.\n\
Set SECUREGIT_AI_API_KEY or ANTHROPIC_API_KEY environment variable."
)
})?;
let api_url = std::env::var("SECUREGIT_AI_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string());
let model = std::env::var("SECUREGIT_AI_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());
Ok(AiConfig {
api_key,
api_url,
model,
})
}
fn get_staged_diff(path: &Path) -> Result<String> {
let repo = crate::ops::open_repo(path)?;
let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
let index = repo.index()?;
let diff = repo.diff_tree_to_index(head_tree.as_ref(), Some(&index), None)?;
let mut output = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
let prefix = match line.origin() {
'+' => "+",
'-' => "-",
' ' => " ",
_ => "",
};
if let Ok(content) = std::str::from_utf8(line.content()) {
output.push_str(prefix);
output.push_str(content);
}
output.len() < MAX_DIFF_BYTES
})?;
if output.is_empty() {
bail!("No staged changes to generate a commit message for.");
}
Ok(output)
}
async fn call_llm(config: &AiConfig, diff: &str) -> Result<String> {
let prompt = format!("{}{}", COMMIT_PROMPT, diff);
let body = serde_json::json!({
"model": config.model,
"max_tokens": 256,
"messages": [{
"role": "user",
"content": prompt
}]
});
let client = reqwest::Client::new();
let response = client
.post(&config.api_url)
.header("x-api-key", &config.api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("AI API request failed ({}): {}", status, text);
}
let json: serde_json::Value = response.json().await?;
let message = json["content"][0]["text"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Unexpected AI API response format"))?
.trim()
.to_string();
Ok(message)
}
pub async fn generate_commit_message(path: &Path) -> Result<String> {
let config = load_config()?;
let diff = get_staged_diff(path)?;
let message = call_llm(&config, &diff).await?;
Ok(message)
}
pub fn confirm_message(suggested: &str, ui: &UI) -> Result<String> {
ui.blank();
ui.section("AI-generated commit message:");
ui.divider();
ui.raw(suggested);
ui.divider();
println!("\n[Y]es, use this / [e]dit / [n]o, cancel");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
match input.trim().to_lowercase().as_str() {
"" | "y" | "yes" => Ok(suggested.to_string()),
"n" | "no" => bail!("Commit cancelled."),
"e" | "edit" => {
println!("Enter your commit message (Ctrl+D when done):");
let mut message = String::new();
loop {
let mut line = String::new();
match std::io::stdin().read_line(&mut line) {
Ok(0) => break,
Ok(_) => message.push_str(&line),
Err(_) => break,
}
}
let trimmed = message.trim().to_string();
if trimmed.is_empty() {
bail!("Empty commit message, aborting.");
}
Ok(trimmed)
}
_ => Ok(suggested.to_string()),
}
}