1use crate::config::MessageLevel;
2
3pub const SYSTEM_PROMPT: &str = "You are an expert engineer who writes clear, precise Git commit messages \
4following Conventional Commits (feat:, fix:, docs:, style:, refactor:, perf:, test:, build:, ci:, chore:). \
5Rules: keep the subject line at most 72 characters, imperative mood, no trailing period, \
6no markdown fences, no extra commentary. Output only the commit message.";
7
8pub fn build_user_prompt(diff: &str, stat: &str, level: MessageLevel) -> String {
9 let style = match level {
10 MessageLevel::Quiet => {
11 "a single one-line commit subject only (no body). Format: `<type>: <short summary>`"
12 }
13 MessageLevel::Normal => {
14 "a concise subject line, a blank line, then a short body with 2-5 bullet points \
15 explaining what changed and why. Format the first line as `<type>: <summary>`"
16 }
17 MessageLevel::Verbose => {
18 "a detailed subject line, a blank line, then a thorough body explaining what changed, \
19 why it changed, and any impact or follow-ups. Format the first line as `<type>: <summary>`"
20 }
21 };
22
23 if stat.trim().is_empty() {
24 format!("Write {style} for these staged changes.\n\nChanges:\n{diff}")
25 } else {
26 format!(
27 "Write {style} for these staged changes.\n\nFiles changed:\n{stat}\n\nChanges:\n{diff}"
28 )
29 }
30}
31
32pub fn parse_commit_message(response: &str) -> (String, Option<String>) {
35 let trimmed = response.trim();
36 let cleaned = strip_code_fence(trimmed);
37
38 if let Some((subject, body)) = cleaned.split_once("\n\n") {
39 let subject = subject.lines().next().unwrap_or(subject).trim().to_string();
40 let body = body.trim();
41 if body.is_empty() {
42 (subject, None)
43 } else {
44 (subject, Some(body.to_string()))
45 }
46 } else {
47 let subject = cleaned.lines().next().unwrap_or("").trim().to_string();
48 (subject, None)
49 }
50}
51
52fn strip_code_fence(s: &str) -> String {
53 let t = s.trim();
54 if let Some(rest) = t.strip_prefix("```") {
55 let after_first_line = rest.split_once('\n').map(|(_, b)| b).unwrap_or("");
57 let inner = after_first_line
58 .trim_end()
59 .strip_suffix("```")
60 .unwrap_or(after_first_line);
61 return inner.trim().to_string();
62 }
63 t.to_string()
64}
65
66pub fn full_commit_text(subject: &str, body: &Option<String>) -> String {
67 match body {
68 Some(b) if !b.trim().is_empty() => format!("{subject}\n\n{b}"),
69 _ => subject.to_string(),
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 #[test]
78 fn parses_subject_and_body() {
79 let (subject, body) = parse_commit_message("feat: add login\n\n- Add JWT\n- Add tests");
80 assert_eq!(subject, "feat: add login");
81 assert_eq!(body.as_deref(), Some("- Add JWT\n- Add tests"));
82 }
83
84 #[test]
85 fn parses_subject_only() {
86 let (subject, body) = parse_commit_message("fix: typo");
87 assert_eq!(subject, "fix: typo");
88 assert!(body.is_none());
89 }
90
91 #[test]
92 fn strips_code_fences() {
93 let (subject, _) = parse_commit_message("```\nfeat: x\n\nbody\n```");
94 assert_eq!(subject, "feat: x");
95 }
96
97 #[test]
98 fn prompt_includes_stat_and_diff() {
99 let p = build_user_prompt("File: a.rs", "a.rs | 1 +", MessageLevel::Normal);
100 assert!(p.contains("a.rs | 1 +"));
101 assert!(p.contains("File: a.rs"));
102 }
103}