fluidattacks-core 0.1.5

Fluid Attacks Core Library
Documentation
use anyhow::{bail, Context, Result};
use tokio::process::Command;

use super::types::{BlameResult, CommitInfo};

pub async fn get_head_commit(repo_path: &str, branch: &str) -> Result<String> {
    let reference = format!("refs/heads/{branch}");
    let output = Command::new("git")
        .args(["-C", repo_path, "rev-parse", &reference])
        .output()
        .await
        .context("running git rev-parse")?;

    if !output.status.success() {
        bail!(
            "git rev-parse failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

pub async fn reset_hard(repo_path: &str) -> Result<()> {
    let status = Command::new("git")
        .args(["-C", repo_path, "reset", "--hard", "HEAD"])
        .status()
        .await
        .context("running git reset --hard")?;

    if !status.success() {
        bail!("git reset --hard failed");
    }
    Ok(())
}

pub async fn set_safe_directory() -> Result<()> {
    let status = Command::new("git")
        .args(["config", "--global", "--add", "safe.directory", "*"])
        .status()
        .await
        .context("running git config safe.directory")?;

    if !status.success() {
        bail!("git config safe.directory failed");
    }
    Ok(())
}

pub async fn disable_quotepath(repo_path: &str) -> Result<()> {
    let git_dir = format!("--git-dir={repo_path}");
    let status = Command::new("git")
        .args([&git_dir, "config", "core.quotepath", "off"])
        .status()
        .await
        .context("running git config core.quotepath")?;

    if !status.success() {
        bail!("git config core.quotepath failed");
    }
    Ok(())
}

pub async fn get_last_commit_info(repo_path: &str, filename: &str) -> Result<Option<CommitInfo>> {
    let output = Command::new("git")
        .args([
            "-C",
            repo_path,
            "log",
            "--max-count",
            "1",
            "--format=%H%n%ce%n%cI",
            "--",
            filename,
        ])
        .output()
        .await
        .context("running git log")?;

    if !output.status.success() {
        bail!(
            "git log failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stdout = stdout.trim();
    if stdout.is_empty() {
        return Ok(None);
    }

    let lines: Vec<&str> = stdout.lines().collect();
    if lines.len() < 3 {
        return Ok(None);
    }

    Ok(Some(CommitInfo {
        sha: lines[0].to_string(),
        author_email: lines[1].to_string(),
        date: lines[2].to_string(),
    }))
}

pub async fn get_line_author(
    repo_path: &str,
    filename: &str,
    line: usize,
    rev: &str,
) -> Result<Option<CommitInfo>> {
    let line_spec = format!("{line},+1");
    let output = Command::new("git")
        .args([
            "-C", repo_path, "blame", "-L", &line_spec, "-l", "-p", "-M", "-C", "-C", rev, "--",
            filename,
        ])
        .output()
        .await
        .context("running git blame")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        if stderr.contains("no such path") || stderr.contains("no such ref") {
            return Ok(None);
        }
        bail!("git blame failed: {}", stderr);
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    parse_blame_porcelain(&stdout)
}

pub async fn get_modified_filenames(repo_path: &str, commit_sha: &str) -> Result<Vec<String>> {
    let output = Command::new("git")
        .args([
            "-C",
            repo_path,
            "diff",
            "--name-only",
            &format!("{commit_sha}..HEAD"),
        ])
        .output()
        .await
        .context("running git diff --name-only")?;

    if !output.status.success() {
        bail!(
            "git diff failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    Ok(stdout
        .lines()
        .filter(|l| !l.is_empty())
        .map(|l| l.to_string())
        .collect())
}

pub async fn is_commit_in_branch(repo_path: &str, branch: &str, commit_sha: &str) -> Result<bool> {
    let output = Command::new("git")
        .args(["-C", repo_path, "branch", "--contains", commit_sha])
        .output()
        .await
        .context("running git branch --contains")?;

    if !output.status.success() {
        return Ok(false);
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    Ok(stdout
        .lines()
        .any(|l| l.trim().trim_start_matches("* ") == branch))
}

pub async fn show_file_at_rev(
    repo_path: &str,
    rev: &str,
    file_path: &str,
) -> Result<Option<String>> {
    let rev_path = format!("{rev}:{file_path}");
    let output = Command::new("git")
        .args(["-C", repo_path, "show", &rev_path])
        .output()
        .await
        .context("running git show")?;

    if !output.status.success() {
        return Ok(None);
    }

    Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
}

#[allow(clippy::too_many_arguments)]
pub async fn blame_reverse(
    repo_path: &str,
    file_path: &str,
    line: usize,
    rev_a: &str,
    rev_b: &str,
    enable_copy_detection: bool,
) -> Result<Option<BlameResult>> {
    let line_spec = format!("{line},+1");
    let rev_range = format!("{rev_a}..{rev_b}");

    let mut args = vec!["-C", repo_path, "blame", "-p", "-M"];
    if enable_copy_detection {
        args.push("-C");
    }
    args.extend(["-L", &line_spec, "--reverse", &rev_range, "--", file_path]);

    let output = Command::new("git")
        .args(&args)
        .output()
        .await
        .context("running git blame --reverse")?;

    if !output.status.success() {
        return Ok(None);
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    parse_blame_reverse_porcelain(&stdout)
}

pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
    let mut sha = String::new();
    let mut author_mail = String::new();
    let mut committer_time = String::new();

    for line in output.lines() {
        if sha.is_empty() && line.len() >= 40 {
            sha = line.split_whitespace().next().unwrap_or("").to_string();
        } else if let Some(val) = line.strip_prefix("author-mail <") {
            author_mail = val.trim_end_matches('>').to_string();
        } else if let Some(val) = line.strip_prefix("committer-time ") {
            committer_time = val.to_string();
        }
    }

    if sha.is_empty() {
        return Ok(None);
    }

    Ok(Some(CommitInfo {
        sha,
        author_email: author_mail,
        date: committer_time,
    }))
}

pub(crate) fn parse_blame_reverse_porcelain(output: &str) -> Result<Option<BlameResult>> {
    let mut rev = String::new();
    let mut line_num: usize = 0;
    let mut path = String::new();

    for line in output.lines() {
        if rev.is_empty() && line.len() >= 40 {
            let parts: Vec<&str> = line.split_whitespace().collect();
            if let Some(hash) = parts.first() {
                rev = hash.to_string();
            }
            if let Some(ln) = parts.get(1) {
                line_num = ln.parse().unwrap_or(0);
            }
        } else if let Some(val) = line.strip_prefix("filename ") {
            path = val.to_string();
        }
    }

    if rev.is_empty() {
        return Ok(None);
    }

    Ok(Some(BlameResult {
        rev,
        line: line_num,
        path,
    }))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_blame_porcelain_valid() {
        let output = "\
abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
author Test\n\
author-mail <dev@example.com>\n\
author-time 1700000000\n\
author-tz +0000\n\
committer Test\n\
committer-mail <dev@example.com>\n\
committer-time 1700000000\n\
committer-tz +0000\n\
summary test commit\n\
filename file.txt\n\
\tcode line\n";
        let result = parse_blame_porcelain(output).unwrap().unwrap();
        assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
        assert_eq!(result.author_email, "dev@example.com");
        assert_eq!(result.date, "1700000000");
    }

    #[test]
    fn test_parse_blame_porcelain_empty() {
        assert!(parse_blame_porcelain("").unwrap().is_none());
    }

    #[test]
    fn test_parse_blame_reverse_porcelain_valid() {
        let output = "\
abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
author Test\n\
filename renamed.py\n";
        let result = parse_blame_reverse_porcelain(output).unwrap().unwrap();
        assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
        assert_eq!(result.line, 7);
        assert_eq!(result.path, "renamed.py");
    }

    #[test]
    fn test_parse_blame_reverse_porcelain_empty() {
        assert!(parse_blame_reverse_porcelain("").unwrap().is_none());
    }
}