quick-commit 0.2.6

Commit changes quickly
use serde_json::{json, Value};

use crate::config::SemanticType;

fn strip_code_fences(s: &str) -> &str {
    let s = s.trim();
    let s = if s.starts_with("```json") {
        &s[7..]
    } else if s.starts_with("```") {
        &s[3..]
    } else {
        return s;
    };
    let s = s.trim_start_matches('\n');
    if let Some(end) = s.rfind("```") {
        s[..end].trim()
    } else {
        s.trim()
    }
}

pub fn generate_commit_info(
    diff: &str,
    new_branch: bool,
    api_key: &str,
    model: &str,
    semantic_types: &[SemanticType],
) -> Result<(String, Option<String>), String> {
    let branch_step = if new_branch {
        "\nStep 3 — Write the branch name as: <type>/<short-kebab-case-slug>\n\
              • Must use the SAME type prefix as the commit message\n\
              • 2–5 words in the slug, lowercase, separated by hyphens\n\
              • The slug should describe the change differently from the commit description\n"
    } else {
        ""
    };

    let json_schema = if new_branch {
        "{{\"commit_message\": \"...\", \"branch_name\": \"...\"}}"
    } else {
        "{{\"commit_message\": \"...\"}}"
    };

    let max_name_len = semantic_types
        .iter()
        .map(|t| t.name.len())
        .max()
        .unwrap_or(0);
    let types_list: String = semantic_types
        .iter()
        .map(|t| {
            format!(
                "  {:<width$} — {}",
                t.name,
                t.description,
                width = max_name_len
            )
        })
        .collect::<Vec<_>>()
        .join("\n");

    let type_names: String = semantic_types
        .iter()
        .map(|t| t.name.as_str())
        .collect::<Vec<_>>()
        .join(", ");

    let prompt = format!(
        "Analyze the following diff and generate a commit message{extra}.\n\n\
        Step 1 — Determine the change type. Pick exactly ONE from this list:\n\
        {types_list}\n\n\
        Step 2 — Write the commit message as: <type>: <concise imperative description>\n\
          • Use the imperative mood (\"add\", not \"added\" or \"adds\")\n\
          • Keep it under 72 characters\n\
          • Do NOT capitalize the description\n\
          • No trailing period\n\
        {branch_step}\n\
        Respond ONLY with valid JSON, no markdown fences:\n\
        {json_schema}\n\n\
        Diff:\n{diff}",
        extra = if new_branch { " and branch name" } else { "" },
        types_list = types_list,
        branch_step = branch_step,
        json_schema = json_schema,
        diff = diff,
    );

    let body = json!({
        "model": model,
        "provider": {"order": ["groq"]},
        "messages": [
            {
                "role": "system",
                "content": format!("You are a git commit message generator that strictly follows Conventional Commits. You always classify changes into exactly one type ({}) before writing the message. Respond only with valid JSON, no markdown.", type_names)
            },
            {
                "role": "user",
                "content": prompt
            }
        ],
        "temperature": 0.3
    });

    let resp = ureq::post("https://openrouter.ai/api/v1/chat/completions")
        .set("Authorization", &format!("Bearer {}", api_key))
        .set("Content-Type", "application/json")
        .send_json(&body)
        .map_err(|e| match e {
            ureq::Error::Status(code, response) => {
                let body = response.into_string().unwrap_or_default();
                format!("API error ({}): {}", code, body)
            }
            other => format!("Request failed: {}", other),
        })?;

    let json_resp: Value = resp
        .into_json()
        .map_err(|e| format!("Failed to parse API response: {}", e))?;

    let content = json_resp["choices"][0]["message"]["content"]
        .as_str()
        .ok_or_else(|| "No content in API response".to_string())?;

    let cleaned = strip_code_fences(content);

    let parsed: Value = serde_json::from_str(cleaned)
        .map_err(|e| format!("Failed to parse AI JSON: {} -- raw: {}", e, cleaned))?;

    let commit_message = parsed["commit_message"]
        .as_str()
        .ok_or_else(|| "No commit_message in AI response".to_string())?
        .to_string();

    let branch_name = if new_branch {
        Some(
            parsed["branch_name"]
                .as_str()
                .ok_or_else(|| "No branch_name in AI response".to_string())?
                .to_string(),
        )
    } else {
        None
    };

    Ok((commit_message, branch_name))
}