Skip to main content

auto_commit_rs/
prompt.rs

1use crate::config::AppConfig;
2
3const CONVENTIONAL_COMMIT_SPEC: &str = "\
4Write all commit messages strictly following the Conventional Commits specification.
5
6Use the following format:
7<type>[optional scope][optional !]: <description>
8
9[optional body]
10
11[optional footer(s)]
12
13Rules to follow:
141. Type: MUST be a noun. Use `feat` for new features, `fix` for bug fixes, or other relevant types (e.g., `docs`, `chore`, `refactor`).
152. Scope: OPTIONAL. A noun describing the affected section of the codebase, enclosed in parentheses (e.g., `fix(parser):`).
163. Description: REQUIRED. A concise summary immediately following the type/scope, colon, and space.
174. Body: OPTIONAL. Provide additional context. MUST begin one blank line after the description.
185. Footer: OPTIONAL. MUST begin one blank line after the body. Use token-value pairs (e.g., `Reviewed-by: Name`). Token words must be hyphenated.
196. Breaking Changes: MUST be indicated by either an exclamation mark `!` immediately before the colon (e.g., `feat!:`) OR an uppercase `BREAKING CHANGE: <description>` in the footer.";
20
21const GITMOJI_UNICODE_SPEC: &str = "\
22Use Gitmoji while still following the Conventional Commits specification above: \
23prepend a relevant emoji in unicode format, then a space, then the conventional type(scope): description. \
24Examples: \u{26a1}\u{fe0f} feat(api): improve response time, \u{1f41b} fix(auth): correct login redirect, \
25\u{2728} feat: add new feature, \u{267b}\u{fe0f} refactor(parser): simplify logic, \u{1f4dd} docs: update README, \u{1f3a8} style(ui): improve layout";
26
27const GITMOJI_SHORTCODE_SPEC: &str = "\
28Use Gitmoji while still following the Conventional Commits specification above: \
29prepend a relevant emoji in :shortcode: format, then a space, then the conventional type(scope): description. \
30Examples: :zap: feat(api): improve response time, :bug: fix(auth): correct login redirect, \
31:sparkles: feat: add new feature, :recycle: refactor(parser): simplify logic, :memo: docs: update README, :art: style(ui): improve layout";
32
33/// Build the full system prompt from config flags
34pub fn build_system_prompt(cfg: &AppConfig) -> String {
35    let mut parts = Vec::new();
36
37    // Base prompt (user-overridable)
38    parts.push(cfg.llm_system_prompt.clone());
39
40    // Conventional commits
41    parts.push(CONVENTIONAL_COMMIT_SPEC.to_string());
42
43    // Gitmoji
44    if cfg.use_gitmoji {
45        let spec = match cfg.gitmoji_format.as_str() {
46            "shortcode" => GITMOJI_SHORTCODE_SPEC,
47            _ => GITMOJI_UNICODE_SPEC,
48        };
49        parts.push(spec.to_string());
50    }
51
52    // One-liner
53    if cfg.one_liner {
54        parts.push("Craft a concise, single sentence, commit message that encapsulates all changes made, with an emphasis on the primary updates. If the modifications share a common theme or scope, mention it succinctly; otherwise, leave the scope out to maintain focus. The goal is to provide a clear and unified overview of the changes in one single message. Output ONLY a single-line commit message in the format: type[optional scope]: description. Do NOT include a body or footer. The entire commit message must fit on one line.".to_string());
55    }
56
57    // Locale
58    if cfg.locale != "en" {
59        parts.push(format!(
60            "Write the commit message in the '{}' locale.",
61            cfg.locale
62        ));
63    }
64
65    // Universal closing instructions
66    parts.push(
67        "Use present tense. Be concise. Output only the raw commit message, nothing else."
68            .to_string(),
69    );
70
71    parts.join("\n\n")
72}
73
74/// Strip common LLM artifacts from the raw response so only the commit message remains.
75///
76/// Handles:
77/// - Markdown code fences (``` or ```commit / ```text / etc.)
78/// - Leading label lines ("Here is your commit message:", "Commit message:", etc.)
79/// - Surrounding quotation marks
80pub fn clean_commit_message(raw: &str) -> String {
81    let s = raw.trim();
82
83    // Strip markdown code fences
84    let s = strip_code_fence(s);
85
86    // Strip a leading label line (everything before the first blank line or
87    // the first line that looks like a conventional commit / gitmoji prefix).
88    let s = strip_label_prefix(s);
89
90    // Strip surrounding straight or curly quotes
91    let s = strip_surrounding_quotes(s);
92
93    s.trim().to_string()
94}
95
96fn strip_code_fence(s: &str) -> &str {
97    // Match opening fence with optional language tag (e.g., ```commit, ```text)
98    if let Some(inner) = s.strip_prefix("```") {
99        // Skip the language tag on the first line
100        let after_tag = inner.trim_start_matches(|c: char| c.is_alphanumeric() || c == '-');
101        // Must start with a newline after the tag
102        if let Some(body) = after_tag.strip_prefix('\n') {
103            if let Some(end) = body.rfind("```") {
104                return body[..end].trim();
105            }
106        }
107    }
108    s
109}
110
111fn strip_label_prefix(s: &str) -> &str {
112    // Common prefixes LLMs put before the actual message
113    let label_patterns: &[&str] = &[
114        "commit message:",
115        "here is the commit message:",
116        "here's the commit message:",
117        "here is your commit message:",
118        "here's your commit message:",
119        "generated commit message:",
120        "suggested commit message:",
121        "the commit message:",
122    ];
123
124    let lower = s.to_lowercase();
125    for pat in label_patterns {
126        if let Some(rest) = lower.strip_prefix(pat) {
127            // Trim blank lines / whitespace after the label
128            return s[pat.len()..][rest.len() - rest.trim_start().len()..].trim_start();
129        }
130    }
131    s
132}
133
134fn strip_surrounding_quotes(s: &str) -> &str {
135    let quote_pairs: &[(char, char)] = &[('"', '"'), ('\'', '\''), ('\u{201c}', '\u{201d}')];
136    for &(open, close) in quote_pairs {
137        if s.starts_with(open) && s.ends_with(close) && s.len() > 1 {
138            return &s[open.len_utf8()..s.len() - close.len_utf8()];
139        }
140    }
141    s
142}