Skip to main content

committor/
prompt.rs

1//! Prompt generation for AI-powered commit message creation
2
3use crate::types::{CommitType, DiffChange};
4
5/// Create a detailed prompt for generating conventional commit messages
6pub fn create_commit_prompt(diff: &str) -> String {
7    let sanitized_diff = sanitize_diff_for_prompt(diff);
8
9    format!(
10        r#"You are an expert software engineer who writes clear, concise conventional commit messages.
11
12Based on the following git diff, generate a single conventional commit message that follows these rules:
13
14## Format
15<type>(<scope>): <description>
16
17## Types (choose the most appropriate):
18- feat: A new feature for the user
19- fix: A bug fix
20- docs: Documentation only changes
21- style: Changes that don't affect code meaning (formatting, missing semi-colons, etc.)
22- refactor: Code change that neither fixes a bug nor adds a feature
23- test: Adding missing tests or correcting existing tests
24- chore: Changes to build process, auxiliary tools, libraries, etc.
25- perf: Code change that improves performance
26- ci: Changes to CI configuration files and scripts
27- build: Changes that affect the build system or external dependencies
28
29## Guidelines:
301. Keep the description under 50 characters
312. Use imperative mood ("add" not "added" or "adds")
323. No period at the end
334. Make scope optional but useful (component, module, file area)
345. Focus on WHAT changed, not HOW it was implemented
356. If multiple changes, choose the most significant one
36
37## Examples:
38- feat(auth): add JWT token validation
39- fix(database): resolve connection timeout
40- docs(readme): update installation guide
41- refactor(utils): simplify error handling
42- test(api): add user endpoint tests
43- chore(deps): update React to v18
44- perf(queries): optimize database indexes
45- ci(github): add automated testing
46- build(webpack): configure production build
47
48## Git Diff:
49```
50{}
51```
52
53Generate ONE conventional commit message (only the message, no explanation):"#,
54        sanitized_diff
55    )
56}
57
58/// Create a prompt for generating multiple commit message options
59pub fn create_multiple_commit_prompt(diff: &str, count: u8) -> String {
60    let sanitized_diff = sanitize_diff_for_prompt(diff);
61
62    format!(
63        r#"You are an expert software engineer who writes clear, concise conventional commit messages.
64
65Based on the following git diff, generate {} different conventional commit message options that follow these rules:
66
67## Format
68<type>(<scope>): <description>
69
70## Types:
71- feat: A new feature
72- fix: A bug fix
73- docs: Documentation changes
74- style: Formatting changes
75- refactor: Code restructuring
76- test: Test additions/changes
77- chore: Maintenance tasks
78- perf: Performance improvements
79- ci: CI/CD changes
80- build: Build system changes
81
82## Guidelines:
831. Each message under 50 characters
842. Use imperative mood
853. No period at the end
864. Optional but useful scope
875. Focus on WHAT changed
886. Provide variety in scope and perspective
89
90## Git Diff:
91```
92{}
93```
94
95Generate {} different conventional commit messages (one per line, no numbering or explanation):"#,
96        count, sanitized_diff, count
97    )
98}
99
100/// Create a prompt for analyzing commit message quality
101pub fn create_analysis_prompt(message: &str) -> String {
102    format!(
103        r#"You are an expert in conventional commit standards. Analyze this commit message:
104
105"{}"
106
107Provide feedback on:
1081. Conventional commit format compliance
1092. Clarity and conciseness
1103. Appropriate type and scope
1114. Imperative mood usage
1125. Length (should be under 50 characters)
113
114Rate from 1-10 and suggest improvements if needed.
115
116Response format:
117Score: X/10
118Issues: [list any issues]
119Suggestions: [list improvements]"#,
120        message
121    )
122}
123
124/// Create a prompt with context about the repository
125pub fn create_contextual_commit_prompt(diff: &str, context: &RepositoryContext) -> String {
126    let sanitized_diff = sanitize_diff_for_prompt(diff);
127
128    format!(
129        r#"You are an expert software engineer writing a conventional commit message.
130
131## Repository Context:
132- Language: {}
133- Project Type: {}
134- Branch: {}
135- Files Changed: {}
136
137## Recent Commits:
138{}
139
140## Current Changes:
141```
142{}
143```
144
145Based on this context and the git diff, generate a conventional commit message that:
1461. Follows the format: <type>(<scope>): <description>
1472. Is contextually appropriate for this project
1483. Maintains consistency with recent commit style
1494. Uses the most appropriate type and scope
1505. Keeps description under 50 characters
1516. Uses imperative mood
152
153Generate ONE conventional commit message:"#,
154        context.language,
155        context.project_type,
156        context.branch,
157        context.files_changed,
158        context.recent_commits.join("\n"),
159        sanitized_diff
160    )
161}
162
163/// Create a prompt for fixing an invalid commit message
164pub fn create_fix_commit_prompt(invalid_message: &str, issues: &[String]) -> String {
165    format!(
166        r#"You are an expert in conventional commit standards. Fix this commit message:
167
168Original message: "{}"
169
170Issues found:
171{}
172
173Requirements:
1741. Use format: <type>(<scope>): <description>
1752. Valid types: feat, fix, docs, style, refactor, test, chore, perf, ci, build
1763. Description under 50 characters
1774. Imperative mood
1785. No period at the end
1796. Meaningful scope (optional but recommended)
180
181Generate the corrected conventional commit message:"#,
182        invalid_message,
183        issues
184            .iter()
185            .enumerate()
186            .map(|(i, issue)| format!("{}. {}", i + 1, issue))
187            .collect::<Vec<_>>()
188            .join("\n")
189    )
190}
191
192/// Sanitize diff content for use in prompts
193fn sanitize_diff_for_prompt(diff: &str) -> String {
194    let lines: Vec<&str> = diff.lines().collect();
195    let mut sanitized = String::new();
196    let mut line_count = 0;
197    const MAX_LINES: usize = 100;
198    const MAX_LINE_LENGTH: usize = 150;
199
200    for line in lines {
201        if line_count >= MAX_LINES {
202            sanitized.push_str("... (diff truncated for brevity)\n");
203            break;
204        }
205
206        // Skip lines that might contain sensitive information
207        if contains_sensitive_info(line) {
208            sanitized.push_str("... (line with sensitive info removed)\n");
209            continue;
210        }
211
212        // Truncate very long lines
213        if line.len() > MAX_LINE_LENGTH {
214            sanitized.push_str(&line[..MAX_LINE_LENGTH]);
215            sanitized.push_str("... (line truncated)\n");
216        } else {
217            sanitized.push_str(line);
218            sanitized.push('\n');
219        }
220
221        line_count += 1;
222    }
223
224    sanitized
225}
226
227/// Check if a line contains potentially sensitive information
228fn contains_sensitive_info(line: &str) -> bool {
229    let line_lower = line.to_lowercase();
230
231    // Common patterns for sensitive information
232    let sensitive_patterns = [
233        "password",
234        "secret",
235        "token",
236        "api_key",
237        "private_key",
238        "auth_token",
239        "access_token",
240        "client_secret",
241        "client_id",
242        "database_url",
243        "connection_string",
244        "credentials",
245        "ssh_key",
246        "private_key",
247        "public_key",
248        "cert",
249        "certificate",
250        "bearer",
251        "authorization",
252        "x-api-key",
253        "x-auth-token",
254    ];
255
256    sensitive_patterns
257        .iter()
258        .any(|pattern| line_lower.contains(pattern))
259}
260
261/// Get commit type suggestions based on file changes
262pub fn suggest_commit_type(changes: &[DiffChange]) -> Vec<CommitType> {
263    let mut suggestions = Vec::new();
264
265    // Analyze file patterns to suggest appropriate types
266    let has_test_files = changes.iter().any(|c| {
267        c.file_path.contains("test")
268            || c.file_path.contains("spec")
269            || c.file_path.ends_with("_test.rs")
270            || c.file_path.ends_with(".test.js")
271            || c.file_path.ends_with(".spec.js")
272    });
273
274    let has_doc_files = changes.iter().any(|c| {
275        c.file_path.contains("README")
276            || c.file_path.contains("CHANGELOG")
277            || c.file_path.ends_with(".md")
278            || c.file_path.contains("docs/")
279            || c.file_path.contains("documentation")
280    });
281
282    let has_config_files = changes.iter().any(|c| {
283        c.file_path.contains("Cargo.toml")
284            || c.file_path.contains("package.json")
285            || c.file_path.contains("Dockerfile")
286            || c.file_path.contains("docker-compose")
287            || c.file_path.contains(".yml")
288            || c.file_path.contains(".yaml")
289            || c.file_path.contains("Makefile")
290    });
291
292    let has_ci_files = changes.iter().any(|c| {
293        c.file_path.contains(".github/")
294            || c.file_path.contains(".gitlab-ci")
295            || c.file_path.contains("ci/")
296            || c.file_path.contains("scripts/")
297    });
298
299    // Suggest types based on file patterns
300    if has_test_files {
301        suggestions.push(CommitType::Test);
302    }
303    if has_doc_files {
304        suggestions.push(CommitType::Docs);
305    }
306    if has_config_files {
307        suggestions.push(CommitType::Build);
308        suggestions.push(CommitType::Chore);
309    }
310    if has_ci_files {
311        suggestions.push(CommitType::Ci);
312    }
313
314    // Add common types if no specific patterns found
315    if suggestions.is_empty() {
316        suggestions.extend([CommitType::Feat, CommitType::Fix, CommitType::Refactor]);
317    }
318
319    suggestions
320}
321
322/// Repository context for better commit message generation
323#[derive(Debug, Clone)]
324pub struct RepositoryContext {
325    pub language: String,
326    pub project_type: String,
327    pub branch: String,
328    pub files_changed: String,
329    pub recent_commits: Vec<String>,
330}
331
332impl RepositoryContext {
333    /// Create a new repository context
334    pub fn new() -> Self {
335        Self {
336            language: "Unknown".to_string(),
337            project_type: "Unknown".to_string(),
338            branch: "main".to_string(),
339            files_changed: "0".to_string(),
340            recent_commits: Vec::new(),
341        }
342    }
343
344    /// Detect primary language from file extensions
345    pub fn detect_language(changes: &[DiffChange]) -> String {
346        let mut language_counts = std::collections::HashMap::new();
347
348        for change in changes {
349            if let Some(ext) = std::path::Path::new(&change.file_path).extension() {
350                let lang = match ext.to_str() {
351                    Some("rs") => "Rust",
352                    Some("js") | Some("ts") => "JavaScript/TypeScript",
353                    Some("py") => "Python",
354                    Some("java") => "Java",
355                    Some("cpp") | Some("cc") | Some("cxx") => "C++",
356                    Some("c") | Some("h") => "C",
357                    Some("go") => "Go",
358                    Some("rb") => "Ruby",
359                    Some("php") => "PHP",
360                    Some("cs") => "C#",
361                    Some("kt") => "Kotlin",
362                    Some("swift") => "Swift",
363                    Some("dart") => "Dart",
364                    Some("scala") => "Scala",
365                    Some("clj") => "Clojure",
366                    Some("hs") => "Haskell",
367                    Some("elm") => "Elm",
368                    Some("ex") => "Elixir",
369                    Some("erl") => "Erlang",
370                    Some("nim") => "Nim",
371                    Some("zig") => "Zig",
372                    _ => "Other",
373                };
374
375                *language_counts.entry(lang).or_insert(0) += 1;
376            }
377        }
378
379        language_counts
380            .into_iter()
381            .max_by_key(|(_, count)| *count)
382            .map(|(lang, _)| lang.to_string())
383            .unwrap_or_else(|| "Mixed".to_string())
384    }
385
386    /// Detect project type from file patterns
387    pub fn detect_project_type(changes: &[DiffChange]) -> String {
388        let files: Vec<&str> = changes.iter().map(|c| c.file_path.as_str()).collect();
389
390        if files.iter().any(|f| f.contains("Cargo.toml")) {
391            "Rust Project"
392        } else if files.iter().any(|f| f.contains("package.json")) {
393            "Node.js Project"
394        } else if files
395            .iter()
396            .any(|f| f.contains("requirements.txt") || f.contains("setup.py"))
397        {
398            "Python Project"
399        } else if files
400            .iter()
401            .any(|f| f.contains("pom.xml") || f.contains("build.gradle"))
402        {
403            "Java Project"
404        } else if files.iter().any(|f| f.contains("go.mod")) {
405            "Go Project"
406        } else if files.iter().any(|f| f.contains("Gemfile")) {
407            "Ruby Project"
408        } else if files.iter().any(|f| f.contains("composer.json")) {
409            "PHP Project"
410        } else if files.iter().any(|f| f.contains("pubspec.yaml")) {
411            "Dart/Flutter Project"
412        } else if files.iter().any(|f| f.contains("Package.swift")) {
413            "Swift Project"
414        } else {
415            "Generic Project"
416        }
417        .to_string()
418    }
419}
420
421impl Default for RepositoryContext {
422    fn default() -> Self {
423        Self::new()
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::types::DiffChangeType;
431
432    #[test]
433    fn test_sanitize_diff_for_prompt() {
434        let diff = "normal line\npassword=secret123\napi_key=sk-1234567890\nanother line";
435        let sanitized = sanitize_diff_for_prompt(diff);
436
437        assert!(!sanitized.contains("secret123"));
438        assert!(!sanitized.contains("sk-1234567890"));
439        assert!(sanitized.contains("normal line"));
440        assert!(sanitized.contains("another line"));
441    }
442
443    #[test]
444    fn test_contains_sensitive_info() {
445        assert!(contains_sensitive_info("password=secret123"));
446        assert!(contains_sensitive_info("API_KEY=sk-1234567890"));
447        assert!(contains_sensitive_info(
448            "database_url=postgres://user:pass@host"
449        ));
450        assert!(contains_sensitive_info("Authorization: Bearer token123"));
451
452        assert!(!contains_sensitive_info("normal code line"));
453        assert!(!contains_sensitive_info("function test() {}"));
454    }
455
456    #[test]
457    fn test_suggest_commit_type() {
458        let test_changes = vec![DiffChange {
459            file_path: "src/lib_test.rs".to_string(),
460            change_type: DiffChangeType::Modified,
461            additions: 5,
462            deletions: 2,
463        }];
464
465        let suggestions = suggest_commit_type(&test_changes);
466        assert!(suggestions.contains(&CommitType::Test));
467
468        let doc_changes = vec![DiffChange {
469            file_path: "README.md".to_string(),
470            change_type: DiffChangeType::Modified,
471            additions: 10,
472            deletions: 3,
473        }];
474
475        let suggestions = suggest_commit_type(&doc_changes);
476        assert!(suggestions.contains(&CommitType::Docs));
477    }
478
479    #[test]
480    fn test_detect_language() {
481        let changes = vec![
482            DiffChange {
483                file_path: "src/main.rs".to_string(),
484                change_type: DiffChangeType::Modified,
485                additions: 10,
486                deletions: 5,
487            },
488            DiffChange {
489                file_path: "src/lib.rs".to_string(),
490                change_type: DiffChangeType::Added,
491                additions: 20,
492                deletions: 0,
493            },
494        ];
495
496        let language = RepositoryContext::detect_language(&changes);
497        assert_eq!(language, "Rust");
498    }
499
500    #[test]
501    fn test_detect_project_type() {
502        let rust_changes = vec![DiffChange {
503            file_path: "Cargo.toml".to_string(),
504            change_type: DiffChangeType::Modified,
505            additions: 2,
506            deletions: 1,
507        }];
508
509        let project_type = RepositoryContext::detect_project_type(&rust_changes);
510        assert_eq!(project_type, "Rust Project");
511
512        let node_changes = vec![DiffChange {
513            file_path: "package.json".to_string(),
514            change_type: DiffChangeType::Modified,
515            additions: 3,
516            deletions: 0,
517        }];
518
519        let project_type = RepositoryContext::detect_project_type(&node_changes);
520        assert_eq!(project_type, "Node.js Project");
521    }
522
523    #[test]
524    fn test_create_commit_prompt() {
525        let diff = "diff --git a/src/main.rs b/src/main.rs\n+fn new_function() {}";
526        let prompt = create_commit_prompt(diff);
527
528        assert!(prompt.contains("conventional commit"));
529        assert!(prompt.contains("feat"));
530        assert!(prompt.contains("fix"));
531        assert!(prompt.contains("docs"));
532        assert!(prompt.contains(diff));
533    }
534
535    #[test]
536    fn test_create_analysis_prompt() {
537        let message = "feat(auth): add JWT validation";
538        let prompt = create_analysis_prompt(message);
539
540        assert!(prompt.contains("conventional commit"));
541        assert!(prompt.contains("Score:"));
542        assert!(prompt.contains("Issues:"));
543        assert!(prompt.contains("Suggestions:"));
544        assert!(prompt.contains(message));
545    }
546}