quick-commit 0.2.7

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

use crate::config::SemanticType;

pub struct QueryStats {
    pub total_time_ms: Option<u128>,
    pub input_tokens: Option<u64>,
    pub output_tokens: Option<u64>,
}

pub struct GeneratedCommitInfo {
    pub commit_message: String,
    pub branch_name: Option<String>,
    pub stats: QueryStats,
}

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<GeneratedCommitInfo, String> {
    let total_start = Instant::now();
    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 generally 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,
        "reasoning": { "effort": "minimal" },
        "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 request_start = Instant::now();
    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 local_total_time_ms = total_start.elapsed().as_millis();

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

    let generation_id = json_resp["id"].as_str();

    let input_tokens = json_resp["usage"]["prompt_tokens"].as_u64();
    let output_tokens = json_resp["usage"]["completion_tokens"].as_u64();
    let total_time_ms = generation_id
        .and_then(|id| fetch_generation_timing(id, api_key).ok())
        .unwrap_or(Some(
            local_total_time_ms.max(request_start.elapsed().as_millis()),
        ));

    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(GeneratedCommitInfo {
        commit_message,
        branch_name,
        stats: QueryStats {
            total_time_ms,
            input_tokens,
            output_tokens,
        },
    })
}

fn fetch_generation_timing(id: &str, api_key: &str) -> Result<Option<u128>, String> {
    let resp = ureq::get("https://openrouter.ai/api/v1/generation")
        .set("Authorization", &format!("Bearer {}", api_key))
        .query("id", id)
        .call()
        .map_err(|e| match e {
            ureq::Error::Status(code, response) => {
                let body = response.into_string().unwrap_or_default();
                format!("Generation API error ({}): {}", code, body)
            }
            other => format!("Generation request failed: {}", other),
        })?;

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

    let latency_ms = json_resp["data"]["latency"]
        .as_f64()
        .map(|ms| ms.round() as u128);
    let generation_time_ms = json_resp["data"]["generation_time"]
        .as_f64()
        .map(|ms| ms.round() as u128);
    Ok(match (latency_ms, generation_time_ms) {
        (Some(latency), Some(generation)) => Some(latency + generation),
        (Some(latency), None) => Some(latency),
        (None, Some(generation)) => Some(generation),
        (None, None) => None,
    })
}