fluidattacks-core 0.1.5

Fluid Attacks Core Library
Documentation
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_string(),
            line,
            rev: rev_b.to_string(),
        }));
    }

    let resolved_a = cli::get_head_commit(repo, rev_a)
        .await
        .unwrap_or_else(|_| rev_a.to_string());
    let resolved_b = cli::get_head_commit(repo, rev_b)
        .await
        .unwrap_or_else(|_| rev_b.to_string());

    let content_a = cli::show_file_at_rev(repo, &resolved_a, file).await?;
    let content_a = match content_a {
        Some(c) => c,
        None => return Ok(None),
    };
    let lines_a: Vec<&str> = content_a.lines().collect();
    if line == 0 || line > lines_a.len() {
        bail!(
            "line {line} out of range (file has {} lines at {rev_a})",
            lines_a.len()
        );
    }
    let anchor_text = lines_a[line - 1];

    let blame_result =
        cli::blame_reverse(repo, file, line, &resolved_a, &resolved_b, false).await?;

    let blame_result = match blame_result {
        Some(r) => Some(r),
        None => cli::blame_reverse(repo, file, line, &resolved_a, &resolved_b, true).await?,
    };

    let blame_result = match blame_result {
        Some(r) => r,
        None => return Ok(None),
    };

    if blame_result.rev != resolved_b {
        return Ok(None);
    }

    let target_file = if blame_result.path.is_empty() {
        file.to_string()
    } else {
        blame_result.path.clone()
    };

    let content_b = cli::show_file_at_rev(repo, &resolved_b, &target_file).await?;
    let content_b = match content_b {
        Some(c) => c,
        None => return Ok(None),
    };

    let lines_b: Vec<&str> = content_b.lines().collect();
    if blame_result.line == 0 || blame_result.line > lines_b.len() {
        return Ok(None);
    }
    let new_text = lines_b[blame_result.line - 1];

    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)
    }
}

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() + 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);
    }
}