cargo-deltabuild 0.1.0

Detects which crates in a Cargo workspace are affected by changes in a Git feature branch.
use normpath::PathExt;
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::config::GitConfig;
use crate::error::*;

#[derive(Debug, Clone)]
pub struct GitDiff {
    pub changed: Vec<PathBuf>,
    pub deleted: Vec<PathBuf>,
}

pub fn diff(workspace_path: &Path, config: Option<GitConfig>) -> Result<GitDiff> {
    let remote_branch = config
        .as_ref()
        .and_then(|d| d.remote_branch.as_deref())
        .unwrap_or("origin/master");

    let merge_base_output = Command::new("git")
        .arg("merge-base")
        .arg("HEAD")
        .arg(remote_branch)
        .current_dir(workspace_path)
        .output()
        .map_err(|e| Error::Git(format!("Failed to run git merge-base: {}", e)))?;

    if !merge_base_output.status.success() {
        let stderr = String::from_utf8_lossy(&merge_base_output.stderr);
        return Err(Error::Git(format!("git merge-base failed: {}", stderr)));
    }

    let merge_base = String::from_utf8(merge_base_output.stdout)
        .map_err(|e| Error::Git(format!("Invalid UTF-8 in git merge-base output: {}", e)))?
        .trim()
        .to_string();

    let diff_output = Command::new("git")
        .arg("diff")
        .arg("--name-only")
        .arg(format!("{}..HEAD", merge_base))
        .current_dir(workspace_path)
        .output()
        .map_err(|e| Error::Git(format!("Failed to run git diff: {}", e)))?;

    if !diff_output.status.success() {
        let stderr = String::from_utf8_lossy(&diff_output.stderr);
        return Err(Error::Git(format!("git diff failed: {}", stderr)));
    }

    let diff_output_str = String::from_utf8(diff_output.stdout)
        .map_err(|e| Error::Git(format!("Invalid UTF-8 in git diff output: {}", e)))?;

    let all_file_paths: Vec<PathBuf> = diff_output_str
        .lines()
        .filter(|line| !line.trim().is_empty())
        .map(|line| {
            let path = workspace_path.join(line.trim());
            match path.normalize() {
                Ok(normalized) => normalized.into_path_buf(),
                Err(_) => path,
            }
        })
        .collect();

    let changed: Vec<PathBuf> = all_file_paths
        .iter()
        .filter(|path| path.exists())
        .filter_map(|path| {
            path.strip_prefix(workspace_path)
                .ok()
                .map(|p| p.to_path_buf())
        })
        .collect();

    let deleted: Vec<PathBuf> = all_file_paths
        .iter()
        .filter(|path| !path.exists())
        .filter_map(|path| {
            path.strip_prefix(workspace_path)
                .ok()
                .map(|p| p.to_path_buf())
        })
        .collect();

    Ok(GitDiff { changed, deleted })
}

pub fn get_top_level() -> Result<PathBuf> {
    let output = Command::new("git")
        .arg("rev-parse")
        .arg("--show-toplevel")
        .output()
        .map_err(|e| {
            Error::Git(format!(
                "Failed to run git rev-parse --show-toplevel: {}",
                e
            ))
        })?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(Error::Git(format!(
            "git rev-parse --show-toplevel failed: {}",
            stderr
        )));
    }

    let git_root = String::from_utf8(output.stdout)
        .map_err(|e| Error::Git(format!("Invalid UTF-8 in git rev-parse output: {}", e)))?
        .trim()
        .to_string();

    let git_root_path = PathBuf::from(git_root);

    let normalized_path = git_root_path
        .normalize()
        .map(|p| p.into_path_buf())
        .unwrap_or(git_root_path);

    Ok(normalized_path)
}