1use crate::config::Config;
7use crate::git;
8use crate::utils::commit_style::CommitStyleProfile;
9use std::collections::HashMap;
10use std::collections::HashSet;
11use std::path::Path;
12
13pub fn split_prompt(
15 diff: &str,
16 context: Option<&str>,
17 config: &Config,
18 full_gitmoji: bool,
19) -> (String, String) {
20 let system_prompt = build_system_prompt(config, full_gitmoji);
21 let user_prompt = build_user_prompt(diff, context, full_gitmoji, config);
22 (system_prompt, user_prompt)
23}
24
25pub fn build_system_prompt(config: &Config, full_gitmoji: bool) -> String {
27 let mut prompt = String::new();
28
29 prompt.push_str("You are an expert at writing clear, concise git commit messages.\n\n");
30
31 prompt.push_str("OUTPUT RULES:\n");
33 prompt.push_str("- Return ONLY the commit message, with no additional explanation, markdown formatting, or code blocks\n");
34 prompt.push_str("- Do not include any reasoning, thinking, analysis, <thinking> tags, or XML-like tags in your response\n");
35 prompt.push_str("- Never explain your choices or provide commentary\n");
36 prompt.push_str(
37 "- If you cannot generate a meaningful commit message, return \"chore: update\"\n\n",
38 );
39
40 if config.learn_from_history.unwrap_or(false) {
42 if let Some(style_guidance) = get_style_guidance(config) {
43 prompt.push_str("REPO STYLE (learned from commit history):\n");
44 prompt.push_str(&style_guidance);
45 prompt.push('\n');
46 }
47 }
48
49 if let Some(locale) = &config.language {
51 prompt.push_str(&format!(
52 "- Generate the commit message in {} language\n",
53 locale
54 ));
55 }
56
57 let commit_type = config.commit_type.as_deref().unwrap_or("conventional");
59 match commit_type {
60 "conventional" => {
61 prompt.push_str("- Use conventional commit format: <type>(<scope>): <description>\n");
62 prompt.push_str(
63 "- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore\n",
64 );
65 if config.omit_scope.unwrap_or(false) {
66 prompt.push_str("- Omit the scope, use format: <type>: <description>\n");
67 }
68 }
69 "gitmoji" => {
70 if full_gitmoji {
71 prompt.push_str("- Use GitMoji format with full emoji specification from https://gitmoji.dev/\n");
72 prompt.push_str("- Common emojis: ✨(feat), 🐛(fix), 📝(docs), 🚀(deploy), ♻️(refactor), ✅(test), 🔧(chore), ⚡(perf), 🎨(style), 📦(build), 👷(ci)\n");
73 prompt.push_str("- For breaking changes, add 💥 after the type\n");
74 } else {
75 prompt.push_str("- Use GitMoji format: <emoji> <type>: <description>\n");
76 prompt.push_str("- Common emojis: 🐛(fix), ✨(feat), 📝(docs), 🚀(deploy), ✅(test), ♻️(refactor), 🔧(chore), ⚡(perf), 🎨(style), 📦(build), 👷(ci)\n");
77 }
78 }
79 _ => {}
80 }
81
82 let max_length = config.description_max_length.unwrap_or(100);
84 prompt.push_str(&format!(
85 "- Keep the description under {} characters\n",
86 max_length
87 ));
88
89 if config.description_capitalize.unwrap_or(true) {
90 prompt.push_str("- Capitalize the first letter of the description\n");
91 }
92
93 if !config.description_add_period.unwrap_or(false) {
94 prompt.push_str("- Do not end the description with a period\n");
95 }
96
97 if config.enable_commit_body.unwrap_or(false) {
99 prompt.push_str("\nCOMMIT BODY (optional):\n");
100 prompt.push_str(
101 "- Add a blank line after the description, then explain WHY the change was made\n",
102 );
103 prompt.push_str("- Use bullet points for multiple changes\n");
104 prompt.push_str("- Wrap body text at 72 characters\n");
105 prompt
106 .push_str("- Focus on motivation and context, not what changed (that's in the diff)\n");
107 }
108
109 prompt
110}
111
112fn get_style_guidance(config: &Config) -> Option<String> {
114 if let Some(cached) = &config.style_profile {
116 return Some(cached.clone());
118 }
119
120 let count = config.history_commits_count.unwrap_or(50);
122
123 match git::get_recent_commit_messages(count) {
124 Ok(commits) => {
125 if commits.is_empty() {
126 return None;
127 }
128
129 let profile = CommitStyleProfile::analyze_from_commits(&commits);
130
131 if profile.is_empty() || commits.len() < 10 {
134 return None;
135 }
136
137 Some(profile.to_prompt_guidance())
138 }
139 Err(e) => {
140 tracing::warn!("Failed to get commit history for style analysis: {}", e);
141 None
142 }
143 }
144}
145
146pub fn build_user_prompt(
148 diff: &str,
149 context: Option<&str>,
150 _full_gitmoji: bool,
151 _config: &Config,
152) -> String {
153 let mut prompt = String::new();
154
155 if let Some(project_context) = get_project_context() {
157 prompt.push_str(&format!("Project Context: {}\n\n", project_context));
158 }
159
160 let file_summary = extract_file_summary(diff);
162 if !file_summary.is_empty() {
163 prompt.push_str(&format!("Files Changed: {}\n\n", file_summary));
164 }
165
166 if diff.contains("---CHUNK") {
168 let chunk_count = diff.matches("---CHUNK").count();
169 if chunk_count > 1 {
170 prompt.push_str(&format!(
171 "Note: This diff was split into {} chunks due to size. Focus on the overall purpose of the changes across all chunks.\n\n",
172 chunk_count
173 ));
174 } else {
175 prompt.push_str("Note: The diff was split into chunks due to size. Focus on the overall purpose of the changes.\n\n");
176 }
177 }
178
179 if let Some(ctx) = context {
181 prompt.push_str(&format!("Additional context: {}\n\n", ctx));
182 }
183
184 prompt.push_str("Generate a commit message for the following git diff:\n");
185 prompt.push_str("```diff\n");
186 prompt.push_str(diff);
187 prompt.push_str("\n```\n");
188
189 prompt.push_str("\nRemember: Return ONLY the commit message, no explanations or markdown.");
191
192 prompt
193}
194
195pub fn extract_file_summary(diff: &str) -> String {
197 let mut files: Vec<String> = Vec::new();
198 let mut extensions: HashSet<String> = HashSet::new();
199 let mut file_types: HashMap<String, usize> = HashMap::new();
200
201 for line in diff.lines() {
202 if line.starts_with("+++ b/") {
203 let path = line.strip_prefix("+++ b/").unwrap_or(line);
204 if path != "/dev/null" {
205 files.push(path.to_string());
206 if let Some(ext) = std::path::Path::new(path).extension() {
208 if let Some(ext_str) = ext.to_str() {
209 let ext_lower = ext_str.to_lowercase();
210 extensions.insert(ext_lower.clone());
211
212 let category = categorize_file_type(&ext_lower);
214 *file_types.entry(category).or_insert(0) += 1;
215 }
216 } else {
217 if path.contains("Makefile")
219 || path.contains("Dockerfile")
220 || path.contains("LICENSE")
221 {
222 *file_types.entry("config".to_string()).or_insert(0) += 1;
223 }
224 }
225 }
226 }
227 }
228
229 if files.is_empty() {
230 return String::new();
231 }
232
233 let mut summary = format!("{} file(s)", files.len());
235
236 if !file_types.is_empty() {
238 let mut type_list: Vec<_> = file_types.into_iter().collect();
239 type_list.sort_by(|a, b| b.1.cmp(&a.1)); let type_str: Vec<_> = type_list
242 .iter()
243 .map(|(t, c)| format!("{} {}", c, t))
244 .collect();
245 summary.push_str(&format!(" ({})", type_str.join(", ")));
246 }
247
248 if !extensions.is_empty() && extensions.len() <= 5 {
250 let ext_list: Vec<_> = extensions.into_iter().collect();
251 summary.push_str(&format!(" [.{}]", ext_list.join(", .")));
252 }
253
254 if files.len() <= 3 {
256 summary.push_str(&format!(": {}", files.join(", ")));
257 }
258
259 summary
260}
261
262fn categorize_file_type(ext: &str) -> String {
264 match ext {
265 "rs" => "Rust",
267 "py" => "Python",
268 "js" => "JavaScript",
269 "ts" => "TypeScript",
270 "jsx" | "tsx" => "React",
271 "go" => "Go",
272 "java" => "Java",
273 "kt" => "Kotlin",
274 "swift" => "Swift",
275 "c" | "cpp" | "cc" | "h" | "hpp" => "C/C++",
276 "rb" => "Ruby",
277 "php" => "PHP",
278 "cs" => "C#",
279 "scala" => "Scala",
280 "r" => "R",
281 "m" => "Objective-C",
282 "lua" => "Lua",
283 "pl" => "Perl",
284
285 "html" | "htm" => "HTML",
287 "css" | "scss" | "sass" | "less" => "CSS",
288 "vue" => "Vue",
289 "svelte" => "Svelte",
290
291 "json" => "JSON",
293 "yaml" | "yml" => "YAML",
294 "toml" => "TOML",
295 "xml" => "XML",
296 "csv" => "CSV",
297 "sql" => "SQL",
298
299 "md" | "markdown" => "Markdown",
301 "rst" => "reStructuredText",
302 "txt" => "Text",
303
304 "sh" | "bash" | "zsh" | "fish" => "Shell",
306 "ps1" => "PowerShell",
307 "bat" | "cmd" => "Batch",
308 "dockerfile" => "Docker",
309 "makefile" | "mk" => "Make",
310 "cmake" => "CMake",
311
312 _ => "Other",
314 }
315 .to_string()
316}
317
318pub fn get_project_context() -> Option<String> {
320 if let Ok(repo_root) = git::get_repo_root() {
322 let context_path = Path::new(&repo_root).join(".rco").join("context.txt");
323 if context_path.exists() {
324 if let Ok(content) = std::fs::read_to_string(&context_path) {
325 let trimmed = content.trim();
326 if !trimmed.is_empty() {
327 return Some(trimmed.to_string());
328 }
329 }
330 }
331
332 let readme_path = Path::new(&repo_root).join("README.md");
334 if readme_path.exists() {
335 if let Ok(content) = std::fs::read_to_string(&readme_path) {
336 for line in content.lines() {
338 let trimmed = line.trim();
339 if !trimmed.is_empty() && !trimmed.starts_with('#') {
340 let context = if let Some(idx) = trimmed.find('.') {
342 trimmed[..idx + 1].to_string()
343 } else {
344 trimmed.chars().take(100).collect()
345 };
346 if !context.is_empty() {
347 return Some(context);
348 }
349 }
350 }
351 }
352 }
353
354 let cargo_path = Path::new(&repo_root).join("Cargo.toml");
356 if cargo_path.exists() {
357 if let Ok(content) = std::fs::read_to_string(&cargo_path) {
358 let mut in_package = false;
360 for line in content.lines() {
361 let trimmed = line.trim();
362 if trimmed == "[package]" {
363 in_package = true;
364 } else if trimmed.starts_with('[') && trimmed != "[package]" {
365 in_package = false;
366 } else if in_package && trimmed.starts_with("description") {
367 if let Some(idx) = trimmed.find('=') {
368 let desc = trimmed[idx + 1..].trim().trim_matches('"');
369 if !desc.is_empty() {
370 return Some(format!("Rust project: {}", desc));
371 }
372 }
373 }
374 }
375 }
376 }
377
378 let package_path = Path::new(&repo_root).join("package.json");
380 if package_path.exists() {
381 if let Ok(content) = std::fs::read_to_string(&package_path) {
382 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
383 if let Some(desc) = json.get("description").and_then(|d| d.as_str()) {
384 if !desc.is_empty() {
385 return Some(format!("Node.js project: {}", desc));
386 }
387 }
388 }
389 }
390 }
391 }
392
393 None
394}
395
396pub fn build_prompt(
398 diff: &str,
399 context: Option<&str>,
400 config: &Config,
401 full_gitmoji: bool,
402) -> String {
403 let (system, user) = split_prompt(diff, context, config, full_gitmoji);
404 format!("{}\n\n---\n\n{}", system, user)
405}