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
242            .iter()
243            .map(|(t, c)| format!("{} {}", c, t))
244            .collect();
245        summary.push_str(&format!(" ({})", type_str.join(", ")));
246    }
247
248    // Add extension info if not too many
249    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    // Add first few file names if small number
255    if files.len() <= 3 {
256        summary.push_str(&format!(": {}", files.join(", ")));
257    }
258
259    summary
260}
261
262/// Categorize file extension into a type
263fn categorize_file_type(ext: &str) -> String {
264    match ext {
265        // Programming languages
266        "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        // Web
286        "html" | "htm" => "HTML",
287        "css" | "scss" | "sass" | "less" => "CSS",
288        "vue" => "Vue",
289        "svelte" => "Svelte",
290
291        // Data/Config
292        "json" => "JSON",
293        "yaml" | "yml" => "YAML",
294        "toml" => "TOML",
295        "xml" => "XML",
296        "csv" => "CSV",
297        "sql" => "SQL",
298
299        // Documentation
300        "md" | "markdown" => "Markdown",
301        "rst" => "reStructuredText",
302        "txt" => "Text",
303
304        // Build/Config
305        "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
313        _ => "Other",
314    }
315    .to_string()
316}
317
318/// Get project context from .rco/context.txt or README
319pub fn get_project_context() -> Option<String> {
320    // Try .rco/context.txt first
321    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        // Try README.md - extract first paragraph
333        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                // Find first non-empty line that's not a header
337                for line in content.lines() {
338                    let trimmed = line.trim();
339                    if !trimmed.is_empty() && !trimmed.starts_with('#') {
340                        // Return first sentence or up to 100 chars
341                        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        // Try Cargo.toml for Rust projects
355        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                // Extract description from Cargo.toml
359                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        // Try package.json for Node projects
379        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
396/// Build the combined prompt for providers without system message support
397pub 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}