fluidattacks_core_git/
rebase.rs1use anyhow::{bail, Result};
2
3use super::types::RebaseResult;
4use crate::cli;
5
6#[allow(clippy::too_many_arguments)]
7pub async fn rebase(
8 repo: &str,
9 file: &str,
10 line: usize,
11 rev_a: &str,
12 rev_b: &str,
13 similarity_threshold: f64,
14) -> Result<Option<RebaseResult>> {
15 if rev_a == rev_b {
16 return Ok(Some(RebaseResult {
17 path: file.to_owned(),
18 line,
19 rev: rev_b.to_owned(),
20 }));
21 }
22
23 let resolved_a = cli::get_head_commit(repo, rev_a)
24 .await
25 .unwrap_or_else(|_| rev_a.to_owned());
26 let resolved_b = cli::get_head_commit(repo, rev_b)
27 .await
28 .unwrap_or_else(|_| rev_b.to_owned());
29
30 let content_a = cli::show_file_at_rev(repo, &resolved_a, file).await?;
31 let Some(content_a) = content_a else {
32 return Ok(None);
33 };
34 let lines_a: Vec<&str> = content_a.lines().collect();
35 let anchor_text = get_anchor_line(&lines_a, line, rev_a)?;
36
37 let blame_result = get_blame_with_fallback(repo, file, line, &resolved_a, &resolved_b).await?;
38 let Some(blame_result) = blame_result else {
39 return Ok(None);
40 };
41
42 if blame_result.rev != resolved_b {
43 return Ok(None);
44 }
45
46 let target_file = if blame_result.path.is_empty() {
47 file.to_owned()
48 } else {
49 blame_result.path.clone()
50 };
51
52 let new_line = get_new_line(repo, &resolved_b, &target_file, blame_result.line).await?;
53 let Some(new_text) = new_line else {
54 return Ok(None);
55 };
56
57 let ratio = similarity_ratio(anchor_text, &new_text);
58 if ratio >= similarity_threshold {
59 Ok(Some(RebaseResult {
60 path: target_file,
61 line: blame_result.line,
62 rev: blame_result.rev,
63 }))
64 } else {
65 Ok(None)
66 }
67}
68
69async fn get_new_line(repo: &str, rev: &str, file: &str, line: usize) -> Result<Option<String>> {
70 let content = cli::show_file_at_rev(repo, rev, file).await?;
71 let Some(content) = content else {
72 return Ok(None);
73 };
74 let lines: Vec<&str> = content.lines().collect();
75 if line == 0 || line > lines.len() {
76 return Ok(None);
77 }
78 Ok(lines.get(line.saturating_sub(1)).map(|s| (*s).to_owned()))
79}
80
81fn get_anchor_line<'a>(lines: &[&'a str], line: usize, rev: &str) -> Result<&'a str> {
82 if line == 0 || line > lines.len() {
83 bail!(
84 "line {line} out of range (file has {} lines at {rev})",
85 lines.len()
86 );
87 }
88 lines
89 .get(line.saturating_sub(1))
90 .copied()
91 .ok_or_else(|| anyhow::anyhow!("line {line} out of range at {rev}"))
92}
93
94async fn get_blame_with_fallback(
95 repo: &str,
96 file: &str,
97 line: usize,
98 resolved_a: &str,
99 resolved_b: &str,
100) -> Result<Option<super::types::BlameResult>> {
101 let result = cli::blame_reverse(repo, file, line, resolved_a, resolved_b, false).await?;
102 if result.is_some() {
103 return Ok(result);
104 }
105 cli::blame_reverse(repo, file, line, resolved_a, resolved_b, true).await
106}
107
108#[must_use]
109#[allow(clippy::cast_precision_loss, clippy::as_conversions)]
110pub fn similarity_ratio(a: &str, b: &str) -> f64 {
111 use similar::ChangeTag;
112 let diff = similar::TextDiff::from_chars(a, b);
113 let matching: usize = diff
114 .iter_all_changes()
115 .filter(|c| c.tag() == ChangeTag::Equal)
116 .map(|c| c.value().len())
117 .sum();
118 let total = a.len().saturating_add(b.len());
119 if total == 0 {
120 return 1.0;
121 }
122 (2.0 * matching as f64) / total as f64
123}
124
125#[cfg(test)]
126mod tests {
127 use super::similarity_ratio;
128
129 #[test]
130 fn identical_strings() {
131 assert!((similarity_ratio("hello", "hello") - 1.0).abs() < f64::EPSILON);
132 }
133
134 #[test]
135 fn both_empty() {
136 assert!((similarity_ratio("", "") - 1.0).abs() < f64::EPSILON);
137 }
138
139 #[test]
140 fn completely_different() {
141 assert!(similarity_ratio("abc", "xyz") < 0.01);
142 }
143
144 #[test]
145 fn partial_match() {
146 let r = similarity_ratio("abcdef", "abcxyz");
147 assert!(r > 0.4 && r < 0.7, "ratio was {r}");
148 }
149
150 #[test]
151 fn one_empty_one_not() {
152 assert!((similarity_ratio("hello", "") - 0.0).abs() < f64::EPSILON);
153 }
154}