use anyhow::{Result, bail}; /// Parse commit response from LLM pub(crate) fn parse_commit_response( response: &str, format: crate::config::CommitFormat, ) -> Result { // 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 { // 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: [optional scope]: ", 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 { // 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: [optional scope]: ", 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, Option) { 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, pub description: String, pub body: Option, pub footer: Option, 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, ) } }