Skip to main content

fluidattacks_core_git/
rebase.rs

1use 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}