use crate::error::AppError;
use std::collections::HashMap;
const ALLOWED_TYPES: [&str; 9] = [
"feat", "fix", "docs", "refactor", "perf", "test", "chore", "build", "ci",
];
pub fn infer_scope(files: &[String]) -> String {
if files.is_empty() {
return "core".to_string();
}
let ignored_segments = [
"src",
"apps",
"libs",
"tests",
"test",
"bin",
"migrations",
"docs",
"config",
];
let mut counts: HashMap<String, usize> = HashMap::new();
for file in files {
let segments: Vec<&str> = file.split('/').collect();
if segments.is_empty() {
continue;
}
let candidate = if segments[0] == "apps" || segments[0] == "libs" {
segments.get(1).copied()
} else {
segments
.iter()
.copied()
.find(|seg| !ignored_segments.contains(seg) && !seg.starts_with('.'))
};
if let Some(scope) = candidate {
let normalized = scope
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
.to_ascii_lowercase();
if !normalized.trim_matches('-').is_empty() {
*counts
.entry(normalized.trim_matches('-').to_string())
.or_insert(0) += 1;
}
}
}
counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(scope, _)| scope)
.unwrap_or_else(|| "core".to_string())
}
pub fn resolve_scope(scope: &str) -> Result<String, AppError> {
let cleaned = scope.trim();
if cleaned.is_empty() {
return Err(AppError::Message("Scope cannot be empty.".to_string()));
}
let mut out = String::with_capacity(cleaned.len());
for c in cleaned.chars() {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '/' || c == '.' {
out.push(c.to_ascii_lowercase());
}
}
let out = out.trim_matches('-').trim_matches('_').to_string();
if out.is_empty() {
return Err(AppError::Message(
"Scope contains no valid characters. Allowed: a-z, 0-9, -, _, /, .".to_string(),
));
}
if out.chars().count() > 32 {
return Err(AppError::Message(
"Scope is too long. Keep scope length <= 32 characters.".to_string(),
));
}
Ok(out)
}
pub fn build_prompt(
status: &str,
files: &[String],
diff: &str,
scope: &str,
no_body: bool,
) -> String {
let body_instruction = if no_body {
"Return ONLY one subject line. Do not include a body."
} else {
"Return a subject line, blank line, then optional '-' bullet list body lines."
};
format!(
"You are generating a Git commit message.\n\
Output ONLY the commit message text. No markdown. No code fences. No explanations.\n\
Strict format:\n\
1) Subject line must be: type(scope): short summary\n\
2) Allowed types: feat, fix, docs, refactor, perf, test, chore, build, ci\n\
3) Subject max length: 72 characters\n\
4) Use imperative mood\n\
5) Scope must be exactly: {scope}\n\
{body_instruction}\n\
Changed files:\n{}\n\
Git status (porcelain):\n{status}\n\
Diff:\n{diff}\n",
files.join("\n")
)
}
pub fn truncate_middle(input: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let total = input.chars().count();
if total <= max_chars {
return input.to_string();
}
let marker = "\n... [diff truncated] ...\n";
let marker_len = marker.chars().count();
if max_chars <= marker_len + 2 {
return input.chars().take(max_chars).collect();
}
let keep = max_chars - marker_len;
let head_len = keep / 2;
let tail_len = keep - head_len;
let head: String = input.chars().take(head_len).collect();
let tail: String = input
.chars()
.rev()
.take(tail_len)
.collect::<String>()
.chars()
.rev()
.collect();
format!("{head}{marker}{tail}")
}
pub fn normalize_generated_message(raw: &str, no_body: bool) -> Result<String, AppError> {
let mut text = raw.trim().to_string();
if text.starts_with("```") {
let lines: Vec<&str> = text
.lines()
.filter(|line| !line.trim_start().starts_with("```"))
.collect();
text = lines.join("\n");
}
let mut lines: Vec<String> = text
.lines()
.map(|line| line.trim_end().to_string())
.collect();
while lines.first().is_some_and(|line| line.trim().is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|line| line.trim().is_empty()) {
lines.pop();
}
if lines.is_empty() {
return Err(AppError::Message(
"Generated message is empty. Try a different model or rerun with --verbose."
.to_string(),
));
}
lines[0] = trim_subject_to_72(&lines[0]);
if no_body {
return Ok(lines[0].clone());
}
let joined = lines.join("\n").trim().to_string();
if joined.is_empty() {
return Err(AppError::Message(
"Generated message is empty after normalization.".to_string(),
));
}
Ok(joined)
}
pub fn coerce_conventional_message(raw: &str, scope: &str, no_body: bool) -> String {
if validate_commit_message(raw, no_body).is_ok() {
return raw.trim().to_string();
}
let mut lines: Vec<String> = raw
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty() && !line.starts_with("```"))
.collect();
if lines.is_empty() {
return format!("chore({scope}): update project files");
}
let first = sanitize_summary(&lines.remove(0));
let summary = if first.is_empty() {
"update project files".to_string()
} else {
first
};
let subject = format!("chore({scope}): {summary}");
if no_body {
return trim_subject_to_72(&subject);
}
let mut bullets: Vec<String> = lines
.into_iter()
.map(|line| sanitize_summary(&line))
.filter(|line| !line.is_empty())
.take(4)
.map(|line| format!("- {line}"))
.collect();
if bullets.is_empty() {
bullets.push("- update code changes".to_string());
}
format!("{}\n\n{}", trim_subject_to_72(&subject), bullets.join("\n"))
}
fn sanitize_summary(input: &str) -> String {
let cleaned = input
.trim()
.trim_start_matches("-")
.trim_start_matches("*")
.trim_start_matches(':')
.trim();
let mut out = String::new();
for ch in cleaned.chars() {
if ch.is_ascii_control() {
continue;
}
out.push(ch);
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn validate_commit_message(message: &str, no_body: bool) -> Result<(), AppError> {
let lines: Vec<&str> = message.lines().collect();
if lines.is_empty() || lines[0].trim().is_empty() {
return Err(AppError::Message(
"Commit message subject is empty.".to_string(),
));
}
let subject = lines[0].trim();
if subject.chars().count() > 72 {
return Err(AppError::Message(
"Commit subject exceeds 72 characters.".to_string(),
));
}
validate_subject(subject)?;
if no_body {
let has_extra = lines.iter().skip(1).any(|line| !line.trim().is_empty());
if has_extra {
return Err(AppError::Message(
"--no-body was set, but message contains body lines.".to_string(),
));
}
return Ok(());
}
if lines.len() == 1 {
return Ok(());
}
if !lines[1].trim().is_empty() {
return Err(AppError::Message(
"Commit message requires a blank line after subject.".to_string(),
));
}
let mut has_body = false;
for line in lines.iter().skip(2) {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
has_body = true;
if !trimmed.starts_with("- ") {
return Err(AppError::Message(
"Body lines must use '- ' bullet format.".to_string(),
));
}
}
if lines.len() > 2 && !has_body {
return Err(AppError::Message(
"Commit body has no content. Add bullet details or remove extra lines.".to_string(),
));
}
Ok(())
}
fn validate_subject(subject: &str) -> Result<(), AppError> {
let Some(colon_idx) = subject.find(": ") else {
return Err(AppError::Message(
"Subject must match: type(scope): summary".to_string(),
));
};
let header = &subject[..colon_idx];
let summary = subject[colon_idx + 2..].trim();
if summary.is_empty() {
return Err(AppError::Message(
"Commit subject summary cannot be empty.".to_string(),
));
}
let Some(open_idx) = header.find('(') else {
return Err(AppError::Message(
"Subject must include scope: type(scope): summary".to_string(),
));
};
let Some(close_idx) = header.rfind(')') else {
return Err(AppError::Message(
"Subject must include scope: type(scope): summary".to_string(),
));
};
if close_idx != header.len() - 1 || open_idx == 0 || close_idx <= open_idx + 1 {
return Err(AppError::Message(
"Subject must match: type(scope): summary".to_string(),
));
}
let commit_type = &header[..open_idx];
if !ALLOWED_TYPES.contains(&commit_type) {
return Err(AppError::Message(format!(
"Commit type '{commit_type}' is invalid. Allowed: {}",
ALLOWED_TYPES.join(", ")
)));
}
let scope = &header[open_idx + 1..close_idx];
if scope.trim().is_empty() {
return Err(AppError::Message(
"Commit scope cannot be empty.".to_string(),
));
}
let valid_scope = scope
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '/' || c == '.');
if !valid_scope {
return Err(AppError::Message(
"Commit scope has invalid characters. Allowed: a-z, 0-9, -, _, /, .".to_string(),
));
}
Ok(())
}
fn trim_subject_to_72(subject: &str) -> String {
let trimmed = subject.trim();
if trimmed.chars().count() <= 72 {
return trimmed.to_string();
}
trimmed.chars().take(72).collect()
}
#[cfg(test)]
mod tests {
use super::{
coerce_conventional_message, infer_scope, normalize_generated_message, resolve_scope,
truncate_middle, validate_commit_message,
};
#[test]
fn infer_scope_prefers_apps_segment() {
let files = vec![
"apps/auth/src/main.rs".to_string(),
"apps/auth/src/lib.rs".to_string(),
"apps/ecom/src/main.rs".to_string(),
];
assert_eq!(infer_scope(&files), "auth");
}
#[test]
fn resolve_scope_enforces_constraints() {
assert_eq!(
resolve_scope("Auth/API").expect("scope should resolve"),
"auth/api"
);
assert!(resolve_scope("!!!").is_err());
}
#[test]
fn truncation_keeps_marker() {
let input = "a".repeat(200);
let out = truncate_middle(&input, 80);
assert!(out.contains("[diff truncated]"));
assert!(out.chars().count() <= 80);
}
#[test]
fn normalizes_codefence_output() {
let out = normalize_generated_message("```\nfeat(core): add x\n\n- body\n```", false)
.expect("message should normalize");
assert_eq!(out, "feat(core): add x\n\n- body");
}
#[test]
fn validates_commit_message_structure() {
let msg = "feat(core): add validation\n\n- enforce conventional format";
assert!(validate_commit_message(msg, false).is_ok());
assert!(validate_commit_message("oops message", false).is_err());
assert!(validate_commit_message("feat(core): ok\nbody", false).is_err());
}
#[test]
fn coerces_non_conventional_message() {
let raw = "update readme and cli\nmore details";
let coerced = coerce_conventional_message(raw, "core", false);
assert!(validate_commit_message(&coerced, false).is_ok());
}
}