rastray 0.15.0

Blazing-fast static analysis CLI for security, dependency, and performance audits.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;

use thiserror::Error;

#[derive(Debug, Error)]
pub enum GitChangesError {
    #[error("git command not found on PATH; install git or drop --since/--changed-only")]
    GitNotFound,

    #[error("git diff failed for ref '{reference}': {stderr}")]
    GitFailed { reference: String, stderr: String },

    #[error("scan path '{path}' is not inside a git repository")]
    NotARepo { path: PathBuf },
}

pub fn changed_files_since(
    scan_root: &Path,
    reference: &str,
) -> Result<HashSet<PathBuf>, GitChangesError> {
    let canonical_root =
        std::fs::canonicalize(scan_root).unwrap_or_else(|_| scan_root.to_path_buf());

    let repo_root = locate_repo_root(&canonical_root)?;

    let output = Command::new("git")
        .arg("-C")
        .arg(&repo_root)
        .arg("diff")
        .arg("--name-only")
        .arg("--diff-filter=AMRT")
        .arg(reference)
        .arg("HEAD")
        .output()
        .map_err(|err| {
            if err.kind() == std::io::ErrorKind::NotFound {
                GitChangesError::GitNotFound
            } else {
                GitChangesError::GitFailed {
                    reference: reference.to_string(),
                    stderr: err.to_string(),
                }
            }
        })?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        return Err(GitChangesError::GitFailed {
            reference: reference.to_string(),
            stderr,
        });
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut paths = HashSet::new();
    for line in stdout.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let absolute = repo_root.join(trimmed);
        let normalized = std::fs::canonicalize(&absolute).unwrap_or(absolute);
        paths.insert(normalized);
    }
    Ok(paths)
}

fn locate_repo_root(start: &Path) -> Result<PathBuf, GitChangesError> {
    let output = Command::new("git")
        .arg("-C")
        .arg(start)
        .arg("rev-parse")
        .arg("--show-toplevel")
        .output()
        .map_err(|err| {
            if err.kind() == std::io::ErrorKind::NotFound {
                GitChangesError::GitNotFound
            } else {
                GitChangesError::GitFailed {
                    reference: "rev-parse".to_string(),
                    stderr: err.to_string(),
                }
            }
        })?;

    if !output.status.success() {
        return Err(GitChangesError::NotARepo {
            path: start.to_path_buf(),
        });
    }

    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if raw.is_empty() {
        return Err(GitChangesError::NotARepo {
            path: start.to_path_buf(),
        });
    }
    Ok(PathBuf::from(raw))
}

pub fn resolve_reference(since: Option<&str>, changed_only: bool) -> Option<String> {
    if let Some(reference) = since {
        return Some(reference.to_string());
    }
    if changed_only {
        return Some("HEAD~1".to_string());
    }
    None
}

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

    #[test]
    fn resolve_reference_prefers_explicit_since() {
        assert_eq!(
            resolve_reference(Some("origin/main"), true),
            Some("origin/main".to_string())
        );
    }

    #[test]
    fn resolve_reference_defaults_to_head_minus_one_for_changed_only() {
        assert_eq!(resolve_reference(None, true), Some("HEAD~1".to_string()));
    }

    #[test]
    fn resolve_reference_is_none_when_nothing_set() {
        assert_eq!(resolve_reference(None, false), None);
    }

    #[test]
    fn resolve_reference_passes_through_short_refs() {
        assert_eq!(
            resolve_reference(Some("abc1234"), false),
            Some("abc1234".to_string())
        );
    }
}