use anyhow::{bail, Result};
use super::types::RebaseResult;
use crate::git::cli;
#[allow(clippy::too_many_arguments)]
pub async fn rebase(
repo: &str,
file: &str,
line: usize,
rev_a: &str,
rev_b: &str,
similarity_threshold: f64,
) -> Result<Option<RebaseResult>> {
if rev_a == rev_b {
return Ok(Some(RebaseResult {
path: file.to_owned(),
line,
rev: rev_b.to_owned(),
}));
}
let resolved_a = cli::get_head_commit(repo, rev_a)
.await
.unwrap_or_else(|_| rev_a.to_owned());
let resolved_b = cli::get_head_commit(repo, rev_b)
.await
.unwrap_or_else(|_| rev_b.to_owned());
let content_a = cli::show_file_at_rev(repo, &resolved_a, file).await?;
let Some(content_a) = content_a else {
return Ok(None);
};
let lines_a: Vec<&str> = content_a.lines().collect();
let anchor_text = get_anchor_line(&lines_a, line, rev_a)?;
let blame_result = get_blame_with_fallback(repo, file, line, &resolved_a, &resolved_b).await?;
let Some(blame_result) = blame_result else {
return Ok(None);
};
if blame_result.rev != resolved_b {
return Ok(None);
}
let target_file = if blame_result.path.is_empty() {
file.to_owned()
} else {
blame_result.path.clone()
};
let new_line = get_new_line(repo, &resolved_b, &target_file, blame_result.line).await?;
let Some(new_text) = new_line else {
return Ok(None);
};
let ratio = similarity_ratio(anchor_text, &new_text);
if ratio >= similarity_threshold {
Ok(Some(RebaseResult {
path: target_file,
line: blame_result.line,
rev: blame_result.rev,
}))
} else {
Ok(None)
}
}
async fn get_new_line(repo: &str, rev: &str, file: &str, line: usize) -> Result<Option<String>> {
let content = cli::show_file_at_rev(repo, rev, file).await?;
let Some(content) = content else {
return Ok(None);
};
let lines: Vec<&str> = content.lines().collect();
if line == 0 || line > lines.len() {
return Ok(None);
}
Ok(lines.get(line.saturating_sub(1)).map(|s| (*s).to_owned()))
}
fn get_anchor_line<'a>(lines: &[&'a str], line: usize, rev: &str) -> Result<&'a str> {
if line == 0 || line > lines.len() {
bail!(
"line {line} out of range (file has {} lines at {rev})",
lines.len()
);
}
lines
.get(line.saturating_sub(1))
.copied()
.ok_or_else(|| anyhow::anyhow!("line {line} out of range at {rev}"))
}
async fn get_blame_with_fallback(
repo: &str,
file: &str,
line: usize,
resolved_a: &str,
resolved_b: &str,
) -> Result<Option<super::types::BlameResult>> {
let result = cli::blame_reverse(repo, file, line, resolved_a, resolved_b, false).await?;
if result.is_some() {
return Ok(result);
}
cli::blame_reverse(repo, file, line, resolved_a, resolved_b, true).await
}
#[must_use]
#[allow(clippy::cast_precision_loss, clippy::as_conversions)]
pub fn similarity_ratio(a: &str, b: &str) -> f64 {
use similar::ChangeTag;
let diff = similar::TextDiff::from_chars(a, b);
let matching: usize = diff
.iter_all_changes()
.filter(|c| c.tag() == ChangeTag::Equal)
.map(|c| c.value().len())
.sum();
let total = a.len().saturating_add(b.len());
if total == 0 {
return 1.0;
}
(2.0 * matching as f64) / total as f64
}
#[cfg(test)]
mod tests {
use super::similarity_ratio;
#[test]
fn identical_strings() {
assert!((similarity_ratio("hello", "hello") - 1.0).abs() < f64::EPSILON);
}
#[test]
fn both_empty() {
assert!((similarity_ratio("", "") - 1.0).abs() < f64::EPSILON);
}
#[test]
fn completely_different() {
assert!(similarity_ratio("abc", "xyz") < 0.01);
}
#[test]
fn partial_match() {
let r = similarity_ratio("abcdef", "abcxyz");
assert!(r > 0.4 && r < 0.7, "ratio was {r}");
}
#[test]
fn one_empty_one_not() {
assert!((similarity_ratio("hello", "") - 0.0).abs() < f64::EPSILON);
}
}