将 21 个多语言提示词及选择函数迁入 prompts 模块,将 commit 响应 解析与 GeneratedCommit 迁入 parsing 模块,为 rig 迁移期间新旧 实现共享同一份提示词与解析逻辑做准备。无行为变化。
282 lines
7.8 KiB
Rust
282 lines
7.8 KiB
Rust
use anyhow::{Result, bail};
|
|
|
|
/// Parse commit response from LLM
|
|
pub(crate) fn parse_commit_response(
|
|
response: &str,
|
|
format: crate::config::CommitFormat,
|
|
) -> Result<GeneratedCommit> {
|
|
// Clean markdown code fences from the response
|
|
let cleaned = strip_code_fences(response);
|
|
|
|
let lines: Vec<&str> = cleaned
|
|
.lines()
|
|
.map(|l| l.trim())
|
|
.filter(|l| !l.is_empty())
|
|
.collect();
|
|
|
|
if lines.is_empty() {
|
|
let preview: String = response.chars().take(200).collect();
|
|
bail!(
|
|
"LLM returned empty or whitespace-only response. \
|
|
Raw response preview: '{}'. \
|
|
Hint: If using DeepSeek/Kimi with thinking enabled, \
|
|
the model may have returned reasoning_content only. \
|
|
Try disabling thinking mode or switching models.",
|
|
preview
|
|
);
|
|
}
|
|
|
|
// Find the line most likely to be the commit subject
|
|
let first_line = find_commit_subject_line(&lines, format);
|
|
|
|
// Parse based on format
|
|
match format {
|
|
crate::config::CommitFormat::Conventional => {
|
|
parse_conventional_commit(first_line, &lines, response)
|
|
}
|
|
crate::config::CommitFormat::Commitlint => {
|
|
parse_commitlint_commit(first_line, &lines, response)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Remove surrounding markdown code fences (```) from LLM output
|
|
fn strip_code_fences(response: &str) -> String {
|
|
let mut lines: Vec<&str> = response.lines().collect();
|
|
|
|
// Strip leading fence lines (``` or ```lang)
|
|
while lines.first().map_or(false, |l| l.trim().starts_with("```")) {
|
|
lines.remove(0);
|
|
}
|
|
|
|
// Strip trailing fence lines
|
|
while lines.last().map_or(false, |l| l.trim() == "```") {
|
|
lines.pop();
|
|
}
|
|
|
|
lines.join("\n")
|
|
}
|
|
|
|
/// Find the line that is most likely the commit subject among extracted lines
|
|
fn find_commit_subject_line<'a>(
|
|
lines: &[&'a str],
|
|
format: crate::config::CommitFormat,
|
|
) -> &'a str {
|
|
let valid_types = crate::utils::validators::get_commit_types(matches!(
|
|
format,
|
|
crate::config::CommitFormat::Commitlint
|
|
));
|
|
|
|
// First pass: line starting with a known type that also has proper syntax
|
|
// (e.g. "type:", "type(scope):", "type!:")
|
|
for &line in lines {
|
|
let trimmed = line.trim();
|
|
for &t in valid_types {
|
|
if let Some(rest) = trimmed.strip_prefix(t) {
|
|
if rest.starts_with(':') || rest.starts_with('(') || rest.starts_with("!:") {
|
|
return trimmed;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Second pass: any line containing a colon (generic "prefix: description")
|
|
for &line in lines {
|
|
if line.contains(':') {
|
|
return line.trim();
|
|
}
|
|
}
|
|
|
|
// Fallback: return the first line as-is
|
|
lines[0].trim()
|
|
}
|
|
|
|
fn parse_conventional_commit(
|
|
first_line: &str,
|
|
lines: &[&str],
|
|
raw_response: &str,
|
|
) -> Result<GeneratedCommit> {
|
|
// Parse: type(scope)!: description
|
|
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
|
|
if parts.len() != 2 {
|
|
let preview: String = raw_response.chars().take(300).collect();
|
|
bail!(
|
|
"Invalid conventional commit format: missing colon.\n\
|
|
Parsed subject line: '{}'\n\
|
|
Raw response preview: '{}'\n\
|
|
Expected: <type>[optional scope]: <description>",
|
|
first_line,
|
|
preview
|
|
);
|
|
}
|
|
|
|
let type_part = parts[0];
|
|
let description = parts[1].trim();
|
|
|
|
// Extract type, scope, and breaking indicator
|
|
let breaking = type_part.ends_with('!');
|
|
let type_part = type_part.trim_end_matches('!');
|
|
|
|
let (commit_type, scope) = if let Some(start) = type_part.find('(') {
|
|
if let Some(end) = type_part.find(')') {
|
|
let t = &type_part[..start];
|
|
let s = &type_part[start + 1..end];
|
|
(t.to_string(), Some(s.to_string()))
|
|
} else {
|
|
bail!("Invalid scope format: missing closing parenthesis");
|
|
}
|
|
} else {
|
|
(type_part.to_string(), None)
|
|
};
|
|
|
|
// Extract body and footer
|
|
let (body, footer) = extract_body_footer(lines);
|
|
|
|
Ok(GeneratedCommit {
|
|
commit_type,
|
|
scope,
|
|
description: description.to_string(),
|
|
body,
|
|
footer,
|
|
breaking,
|
|
})
|
|
}
|
|
|
|
fn parse_commitlint_commit(
|
|
first_line: &str,
|
|
lines: &[&str],
|
|
raw_response: &str,
|
|
) -> Result<GeneratedCommit> {
|
|
// Similar parsing but with commitlint rules
|
|
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
|
|
if parts.len() != 2 {
|
|
let preview: String = raw_response.chars().take(300).collect();
|
|
bail!(
|
|
"Invalid commit format: missing colon.\n\
|
|
Parsed subject line: '{}'\n\
|
|
Raw response preview: '{}'\n\
|
|
Expected: <type>[optional scope]: <subject>",
|
|
first_line,
|
|
preview
|
|
);
|
|
}
|
|
|
|
let type_part = parts[0];
|
|
let subject = parts[1].trim();
|
|
|
|
let (commit_type, scope) = if let Some(start) = type_part.find('(') {
|
|
if let Some(end) = type_part.find(')') {
|
|
let t = &type_part[..start];
|
|
let s = &type_part[start + 1..end];
|
|
(t.to_string(), Some(s.to_string()))
|
|
} else {
|
|
(type_part.to_string(), None)
|
|
}
|
|
} else {
|
|
(type_part.to_string(), None)
|
|
};
|
|
|
|
let (body, footer) = extract_body_footer(&lines);
|
|
|
|
Ok(GeneratedCommit {
|
|
commit_type,
|
|
scope,
|
|
description: subject.to_string(),
|
|
body,
|
|
footer,
|
|
breaking: false,
|
|
})
|
|
}
|
|
|
|
fn extract_body_footer(lines: &[&str]) -> (Option<String>, Option<String>) {
|
|
if lines.len() <= 1 {
|
|
return (None, None);
|
|
}
|
|
|
|
let rest: Vec<&str> = lines[1..]
|
|
.iter()
|
|
.skip_while(|l| l.trim().is_empty())
|
|
.copied()
|
|
.collect();
|
|
|
|
if rest.is_empty() {
|
|
return (None, None);
|
|
}
|
|
|
|
// Look for footer markers
|
|
let footer_markers = [
|
|
"BREAKING CHANGE:",
|
|
"Closes",
|
|
"Fixes",
|
|
"Refs",
|
|
"Co-authored-by:",
|
|
];
|
|
|
|
let mut body_lines = vec![];
|
|
let mut footer_lines = vec![];
|
|
let mut in_footer = false;
|
|
|
|
for line in &rest {
|
|
if footer_markers.iter().any(|m| line.starts_with(m)) {
|
|
in_footer = true;
|
|
}
|
|
|
|
if in_footer {
|
|
footer_lines.push(*line);
|
|
} else {
|
|
body_lines.push(*line);
|
|
}
|
|
}
|
|
|
|
let body = if body_lines.is_empty() {
|
|
None
|
|
} else {
|
|
Some(body_lines.join("\n"))
|
|
};
|
|
|
|
let footer = if footer_lines.is_empty() {
|
|
None
|
|
} else {
|
|
Some(footer_lines.join("\n"))
|
|
};
|
|
|
|
(body, footer)
|
|
}
|
|
|
|
/// Generated commit structure
|
|
#[derive(Debug, Clone)]
|
|
pub struct GeneratedCommit {
|
|
pub commit_type: String,
|
|
pub scope: Option<String>,
|
|
pub description: String,
|
|
pub body: Option<String>,
|
|
pub footer: Option<String>,
|
|
pub breaking: bool,
|
|
}
|
|
|
|
impl GeneratedCommit {
|
|
/// Format as conventional commit
|
|
pub fn to_conventional(&self) -> String {
|
|
crate::utils::formatter::format_conventional_commit(
|
|
&self.commit_type,
|
|
self.scope.as_deref(),
|
|
&self.description,
|
|
self.body.as_deref(),
|
|
self.footer.as_deref(),
|
|
self.breaking,
|
|
)
|
|
}
|
|
|
|
/// Format as commitlint commit
|
|
pub fn to_commitlint(&self) -> String {
|
|
crate::utils::formatter::format_commitlint_commit(
|
|
&self.commit_type,
|
|
self.scope.as_deref(),
|
|
&self.description,
|
|
self.body.as_deref(),
|
|
self.footer.as_deref(),
|
|
None,
|
|
)
|
|
}
|
|
}
|