1use crate::types::{CommitType, DiffChange};
4
5pub 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
58pub 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
100pub 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
124pub 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
163pub 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
192fn 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 if contains_sensitive_info(line) {
208 sanitized.push_str("... (line with sensitive info removed)\n");
209 continue;
210 }
211
212 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
227fn contains_sensitive_info(line: &str) -> bool {
229 let line_lower = line.to_lowercase();
230
231 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
261pub fn suggest_commit_type(changes: &[DiffChange]) -> Vec<CommitType> {
263 let mut suggestions = Vec::new();
264
265 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 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 if suggestions.is_empty() {
316 suggestions.extend([CommitType::Feat, CommitType::Fix, CommitType::Refactor]);
317 }
318
319 suggestions
320}
321
322#[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 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 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 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}