git_github/
llm.rs

1use crate::git;
2use futures::StreamExt;
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use std::env;
6use std::error::Error;
7use std::fs;
8use std::process::Command;
9
10fn print_banner(title: &str) {
11    let max_width = 100;
12    let min_width = 60;
13    let padding = 4;
14    let raw_width = title.len() + padding * 2;
15    let total_width = std::cmp::min(max_width, std::cmp::max(min_width, raw_width));
16    let banner_line = "=".repeat(total_width);
17
18    let title_padding = (total_width - title.len()) / 2;
19
20    println!("{}", banner_line);
21    println!(
22        "{}{}{}",
23        " ".repeat(title_padding),
24        title,
25        " ".repeat(total_width - title_padding - title.len())
26    );
27    println!("{}", banner_line);
28}
29
30pub fn ai_commit(apply: bool) -> Result<(), Box<dyn Error>> {
31    let path = env::current_dir().map_err(|_| "Failed to get current directory")?;
32    let repo = git::Repo::new(&path);
33    let changes = repo.get_staged_git_changes()?;
34    let config = crate::config::load_config()?;
35    if config.deepseek.api_key.is_empty() {
36        return Err(
37            "Error: No DeepSeek API key found. Please set your API key in the config file.".into(),
38        );
39    }
40
41    let messages = build_prompt_messages(&changes, config.deepseek.prompt);
42
43    print_banner("AI Suggested Commit Message");
44
45    let rt = tokio::runtime::Runtime::new()?;
46    let mut full_message = String::new();
47    let mut current_line = String::new();
48
49    rt.block_on(async {
50        stream_commit_message(
51            &config.deepseek.api_key,
52            messages,
53            config.deepseek.temperature,
54            |content| {
55                for ch in content.chars() {
56                    current_line.push(ch);
57                    if ch == '\n' {
58                        print!("| {}\n", current_line.trim_end());
59                        full_message.push_str(&current_line);
60                        current_line.clear();
61                    }
62                }
63            },
64        )
65        .await
66    })?;
67
68    if !current_line.trim().is_empty() {
69        println!("| {}", current_line.trim_end());
70        full_message.push_str(&current_line);
71    }
72
73    if apply {
74        let commit_id = repo.commit(&full_message.trim())?;
75        print_banner("✅ Commit Successful");
76        println!("Commit ID: {}\n", commit_id);
77    }
78
79    Ok(())
80}
81
82pub fn ai_commit_with_editor() -> Result<(), Box<dyn Error>> {
83    let path = env::current_dir().map_err(|_| "Failed to get current directory")?;
84    let repo = git::Repo::new(&path);
85    let changes = repo.get_staged_git_changes()?;
86    let config = crate::config::load_config()?;
87    if config.deepseek.api_key.is_empty() {
88        return Err(
89            "Error: No DeepSeek API key found. Please set your API key in the config file.".into(),
90        );
91    }
92
93    let messages = build_prompt_messages(&changes, config.deepseek.prompt);
94
95    print_banner("AI Generating Commit Message");
96
97    let rt = tokio::runtime::Runtime::new()?;
98    let mut full_message = String::new();
99    let mut current_line = String::new();
100
101    rt.block_on(async {
102        stream_commit_message(
103            &config.deepseek.api_key,
104            messages,
105            config.deepseek.temperature,
106            |content| {
107                for ch in content.chars() {
108                    current_line.push(ch);
109                    if ch == '\n' {
110                        print!("| {}\n", current_line.trim_end());
111                        full_message.push_str(&current_line);
112                        current_line.clear();
113                    }
114                }
115            },
116        )
117        .await
118    })?;
119
120    if !current_line.trim().is_empty() {
121        println!("| {}", current_line.trim_end());
122        full_message.push_str(&current_line);
123    }
124
125    // Create a temporary file with the AI-generated message
126    let temp_file = env::temp_dir().join("git_github_commit_msg.txt");
127    fs::write(&temp_file, full_message.trim())?;
128
129    print_banner("Opening Editor for Review");
130
131    // Open git commit with the pre-filled message using -e flag to force editor
132    // and inherit stdio to allow interactive editing
133    let status = Command::new("git")
134        .args(&["commit", "-e", "-v", "--template"])
135        .arg(&temp_file)
136        .stdin(std::process::Stdio::inherit())
137        .stdout(std::process::Stdio::inherit())
138        .stderr(std::process::Stdio::inherit())
139        .status()?;
140
141    // Clean up the temporary file
142    let _ = fs::remove_file(&temp_file);
143
144    if !status.success() {
145        return Err("Git commit was cancelled or failed".into());
146    }
147
148    print_banner("✅ Commit Completed");
149
150    Ok(())
151}
152
153fn build_prompt_messages(changes: &str, prompt_opt: Option<String>) -> Vec<ChatMessage> {
154    let mut prompt = r#"
155You are an AI commit message assistant.
156
157Please generate a commit message with the following format:
1581. Title (one short sentence, 50-72 characters max).
1592. A clear bullet-point list of changes (start each line with "- ").
1603. Each line, including bullets, should be under 100 characters.
1614. Keep it concise, consistent, and professional.
162
163Example:
164
165Improve error handling in user authentication
166
167- Add detailed error messages for login failures
168- Handle timeout errors gracefully
169- Refactor error propagation logic for clarity
170"#
171    .to_string();
172    if let Some(config_prompt) = prompt_opt {
173        prompt = config_prompt;
174    }
175
176    vec![
177        ChatMessage {
178            role: "system".to_string(),
179            content: prompt,
180        },
181        ChatMessage {
182            role: "user".to_string(),
183            content: format!("Here are my current Git changes:\n{}", changes),
184        },
185    ]
186}
187
188#[derive(Debug, Serialize)]
189struct ChatMessage {
190    role: String,
191    content: String,
192}
193
194#[derive(Debug, Serialize)]
195struct ChatRequest {
196    model: String,
197    messages: Vec<ChatMessage>,
198    stream: bool,
199    temperature: Option<f32>,
200}
201
202#[derive(Debug, Deserialize)]
203struct StreamResponseChunk {
204    choices: Vec<StreamChoice>,
205}
206
207#[derive(Debug, Deserialize)]
208struct StreamChoice {
209    delta: DeltaMessage,
210}
211
212#[derive(Debug, Deserialize)]
213struct DeltaMessage {
214    content: Option<String>,
215}
216
217async fn stream_commit_message(
218    api_key: &str,
219    messages: Vec<ChatMessage>,
220    temperature: Option<f32>,
221    mut callback: impl FnMut(String),
222) -> Result<(), Box<dyn Error>> {
223    let client = Client::new();
224    let request_body = ChatRequest {
225        model: "deepseek-chat".to_string(),
226        messages,
227        stream: true,
228        temperature,
229    };
230
231    let response = client
232        .post("https://api.deepseek.com/chat/completions")
233        .header("Content-Type", "application/json")
234        .header("Authorization", format!("Bearer {}", api_key))
235        .json(&request_body)
236        .send()
237        .await?;
238
239    if !response.status().is_success() {
240        let err_msg = response.text().await?;
241        return Err(format!("API failed: {}", err_msg).into());
242    }
243
244    let mut stream = response.bytes_stream();
245    while let Some(chunk) = stream.next().await {
246        let chunk = chunk?;
247        let chunk_str = String::from_utf8_lossy(&chunk);
248
249        for line in chunk_str.lines() {
250            if line.starts_with("data:") && line != "data: [DONE]" {
251                let json_str = &line[5..].trim();
252                if let Ok(data) = serde_json::from_str::<StreamResponseChunk>(json_str) {
253                    for choice in data.choices {
254                        if let Some(content) = choice.delta.content {
255                            callback(content);
256                        }
257                    }
258                }
259            }
260        }
261    }
262
263    Ok(())
264}