Skip to main content

rusty_commit/providers/
prompt.rs

1//! Prompt building utilities for commit message generation
2//!
3//! This module contains the core prompt construction logic used by all providers.
4//! It handles system prompts, user prompts, file type categorization, and project context.
5
6use 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
13/// Split the prompt into system and user parts for providers that support it
14pub 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
25/// Build the system prompt part (role definition, rules)
26pub 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    // Core constraints
32    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    // Add style guidance from history if enabled
41    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    // Add locale if specified
50    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    // Add commit type preference
58    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    // Description requirements
83    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    // Add commit body guidance if enabled
98    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
112/// Get style guidance from commit history analysis
113fn get_style_guidance(config: &Config) -> Option<String> {
114    // Get cached style profile or analyze fresh
115    if let Some(cached) = &config.style_profile {
116        // Use cached profile if available
117        return Some(cached.clone());
118    }
119
120    // Analyze recent commits - default now 50 for better learning
121    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            // Only use profile if we have enough confident data (at least 10 commits with patterns)
132            // Increased from 5 to 10 for better confidence
133            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
146/// Build the user prompt part (actual task + diff)
147pub 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    // Add project context if available
156    if let Some(project_context) = get_project_context() {
157        prompt.push_str(&format!("Project Context: {}\n\n", project_context));
158    }
159
160    // Add file type summary with detailed extension info
161    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    // Add chunk indicator with more detail if diff was chunked
167    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    // Add context if provided
180    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    // Add reminder about output format
190    prompt.push_str("\nRemember: Return ONLY the commit message, no explanations or markdown.");
191
192    prompt
193}
194
195/// Extract file type summary from diff
196pub 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                // Extract extension and categorize
207                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                        // Categorize file type
213                        let category = categorize_file_type(&ext_lower);
214                        *file_types.entry(category).or_insert(0) += 1;
215                    }
216                } else {
217                    // No extension - might be a config file or script
218                    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    // Build summary
234    let mut summary = format!("{} file(s)", files.len());
235
236    // Add file type categories
237    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)); // Sort by count descending
240
241        let type_str: Vec<_> = type_list.iter().map(|(t, c)| format!("{} {}", c, t)).collect();
242        summary.push_str(&format!(" ({})", type_str.join(", ")));
243    }
244
245    // Add extension info if not too many
246    if !extensions.is_empty() && extensions.len() <= 5 {
247        let ext_list: Vec<_> = extensions.into_iter().collect();
248        summary.push_str(&format!(" [.{}]", ext_list.join(", .")));
249    }
250
251    // Add first few file names if small number
252    if files.len() <= 3 {
253        summary.push_str(&format!(": {}", files.join(", ")));
254    }
255
256    summary
257}
258
259/// Categorize file extension into a type
260fn categorize_file_type(ext: &str) -> String {
261    match ext {
262        // Programming languages
263        "rs" => "Rust",
264        "py" => "Python",
265        "js" => "JavaScript",
266        "ts" => "TypeScript",
267        "jsx" | "tsx" => "React",
268        "go" => "Go",
269        "java" => "Java",
270        "kt" => "Kotlin",
271        "swift" => "Swift",
272        "c" | "cpp" | "cc" | "h" | "hpp" => "C/C++",
273        "rb" => "Ruby",
274        "php" => "PHP",
275        "cs" => "C#",
276        "scala" => "Scala",
277        "r" => "R",
278        "m" => "Objective-C",
279        "lua" => "Lua",
280        "pl" => "Perl",
281
282        // Web
283        "html" | "htm" => "HTML",
284        "css" | "scss" | "sass" | "less" => "CSS",
285        "vue" => "Vue",
286        "svelte" => "Svelte",
287
288        // Data/Config
289        "json" => "JSON",
290        "yaml" | "yml" => "YAML",
291        "toml" => "TOML",
292        "xml" => "XML",
293        "csv" => "CSV",
294        "sql" => "SQL",
295
296        // Documentation
297        "md" | "markdown" => "Markdown",
298        "rst" => "reStructuredText",
299        "txt" => "Text",
300
301        // Build/Config
302        "sh" | "bash" | "zsh" | "fish" => "Shell",
303        "ps1" => "PowerShell",
304        "bat" | "cmd" => "Batch",
305        "dockerfile" => "Docker",
306        "makefile" | "mk" => "Make",
307        "cmake" => "CMake",
308
309        // Other
310        _ => "Other",
311    }
312    .to_string()
313}
314
315/// Get project context from .rco/context.txt or README
316pub fn get_project_context() -> Option<String> {
317    // Try .rco/context.txt first
318    if let Ok(repo_root) = git::get_repo_root() {
319        let context_path = Path::new(&repo_root).join(".rco").join("context.txt");
320        if context_path.exists() {
321            if let Ok(content) = std::fs::read_to_string(&context_path) {
322                let trimmed = content.trim();
323                if !trimmed.is_empty() {
324                    return Some(trimmed.to_string());
325                }
326            }
327        }
328
329        // Try README.md - extract first paragraph
330        let readme_path = Path::new(&repo_root).join("README.md");
331        if readme_path.exists() {
332            if let Ok(content) = std::fs::read_to_string(&readme_path) {
333                // Find first non-empty line that's not a header
334                for line in content.lines() {
335                    let trimmed = line.trim();
336                    if !trimmed.is_empty() && !trimmed.starts_with('#') {
337                        // Return first sentence or up to 100 chars
338                        let context = if let Some(idx) = trimmed.find('.') {
339                            trimmed[..idx + 1].to_string()
340                        } else {
341                            trimmed.chars().take(100).collect()
342                        };
343                        if !context.is_empty() {
344                            return Some(context);
345                        }
346                    }
347                }
348            }
349        }
350
351        // Try Cargo.toml for Rust projects
352        let cargo_path = Path::new(&repo_root).join("Cargo.toml");
353        if cargo_path.exists() {
354            if let Ok(content) = std::fs::read_to_string(&cargo_path) {
355                // Extract description from Cargo.toml
356                let mut in_package = false;
357                for line in content.lines() {
358                    let trimmed = line.trim();
359                    if trimmed == "[package]" {
360                        in_package = true;
361                    } else if trimmed.starts_with('[') && trimmed != "[package]" {
362                        in_package = false;
363                    } else if in_package && trimmed.starts_with("description") {
364                        if let Some(idx) = trimmed.find('=') {
365                            let desc = trimmed[idx + 1..].trim().trim_matches('"');
366                            if !desc.is_empty() {
367                                return Some(format!("Rust project: {}", desc));
368                            }
369                        }
370                    }
371                }
372            }
373        }
374
375        // Try package.json for Node projects
376        let package_path = Path::new(&repo_root).join("package.json");
377        if package_path.exists() {
378            if let Ok(content) = std::fs::read_to_string(&package_path) {
379                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
380                    if let Some(desc) = json.get("description").and_then(|d| d.as_str()) {
381                        if !desc.is_empty() {
382                            return Some(format!("Node.js project: {}", desc));
383                        }
384                    }
385                }
386            }
387        }
388    }
389
390    None
391}
392
393/// Build the combined prompt for providers without system message support
394pub fn build_prompt(
395    diff: &str,
396    context: Option<&str>,
397    config: &Config,
398    full_gitmoji: bool,
399) -> String {
400    let (system, user) = split_prompt(diff, context, config, full_gitmoji);
401    format!("{}\n\n---\n\n{}", system, user)
402}