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(¤t_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(¤t_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(¤t_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(¤t_line);
123 }
124
125 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 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 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}