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