Skip to main content

si_git_commit/
prompt.rs

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
32/// Parse a model response into `(subject, Option<body>)`.
33/// Strips markdown code fences models sometimes add despite instructions.
34pub 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        // Drop the opening fence line (``` or ```text) and closing fence.
56        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}