194c44c
use std::collections::HashMap;
use crate::prompt::ReviewFinding;
/// 一个补丁文件里可被评论的行号集合(左右两侧分开记录)。
#[derive(Debug, Default, Clone)]
pub struct FileLines {
/// 新增侧(+ 与上下文行)的行号。
pub right: Vec<i32>,
/// 删除侧(- 行)的行号。
pub left: Vec<i32>,
}
/// 解析 unified diff,汇总每个文件的可评论行号。
///
/// 与前端 `splitDiffFiles`/`parseHunks` 的语义保持一致:
/// 头部行(`+++`/`---`)不计入,`\ No newline at end of file` 不推进行号。
pub fn index_diff_lines(diff: &str) -> HashMap<String, FileLines> {
let mut out: HashMap<String, FileLines> = HashMap::new();
let mut current: Option<String> = None;
let mut old_no: i32 = 0;
let mut new_no: i32 = 0;
for line in diff.split('\n') {
if let Some(rest) = line.strip_prefix("diff --git ") {
current = parse_diff_git_target(rest);
if let Some(path) = current.clone() {
out.entry(path).or_default();
}
old_no = 0;
new_no = 0;
continue;
}
let Some(path) = current.clone() else {
continue;
};
if let Some(rest) = line.strip_prefix("+++ ") {
let target = rest.trim();
if target != "/dev/null" {
let path = strip_ab(target);
out.entry(path.clone()).or_default();
current = Some(path);
}
continue;
}
if line.starts_with("--- ") {
continue;
}
if line.starts_with("@@") {
if let Some((old_start, new_start)) = parse_hunk_header(line) {
old_no = old_start;
new_no = new_start;
}
continue;
}
if line.starts_with("\\ No newline") {
continue;
}
if line.starts_with('+') {
out.entry(path.clone()).or_default().right.push(new_no);
new_no += 1;
} else if line.starts_with('-') {
out.entry(path.clone()).or_default().left.push(old_no);
old_no += 1;
} else if line.starts_with(' ') || line.is_empty() {
out.entry(path.clone()).or_default().right.push(new_no);
old_no += 1;
new_no += 1;
}
}
out
}
fn parse_diff_git_target(rest: &str) -> Option<String> {
let trimmed = rest.trim();
let (_, b) = trimmed.split_once(" b/")?;
Some(unquote(b.trim()))
}
fn strip_ab(value: &str) -> String {
let unquoted = unquote(value);
unquoted
.strip_prefix("a/")
.or_else(|| unquoted.strip_prefix("b/"))
.unwrap_or(&unquoted)
.to_string()
}
fn unquote(value: &str) -> String {
let trimmed = value.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
return trimmed[1..trimmed.len() - 1].to_string();
}
trimmed.to_string()
}
fn parse_hunk_header(line: &str) -> Option<(i32, i32)> {
let body = line.strip_prefix("@@")?;
let body = body.split("@@").next()?;
let mut old_start = 0;
let mut new_start = 0;
for token in body.split_whitespace() {
if let Some(rest) = token.strip_prefix('-') {
old_start = rest.split(',').next()?.parse().ok()?;
} else if let Some(rest) = token.strip_prefix('+') {
new_start = rest.split(',').next()?.parse().ok()?;
}
}
Some((old_start, new_start))
}
/// 过滤掉落点不在本次变更行上的 findings,返回(有效 findings, 丢弃条数)。
pub fn validate_findings(
diff: &str,
findings: Vec<ReviewFinding>,
max_findings: usize,
) -> (Vec<ReviewFinding>, usize) {
let index = index_diff_lines(diff);
let mut accepted = Vec::new();
let mut dropped = 0usize;
for mut finding in findings {
let path = finding.path.trim().trim_start_matches("./").to_string();
let side = match finding.side.as_deref() {
Some("left") => "left",
_ => "right",
};
let Some(lines) = index.get(&path) else {
dropped += 1;
continue;
};
let hit = if side == "left" {
lines.left.contains(&finding.line)
} else {
lines.right.contains(&finding.line)
};
if !hit {
dropped += 1;
continue;
}
finding.path = path;
finding.side = Some(side.to_string());
if finding.severity.is_none() {
finding.severity = Some("minor".to_string());
}
accepted.push(finding);
if accepted.len() >= max_findings {
break;
}
}
(accepted, dropped)
}