ars-pr-cover 0.1.2

Runs a coverage runner and filters coverage file based on changed files relative to current branch
use clap::Parser;
use dirs::home_dir;
use duct::cmd;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process::exit;
use xmltree::{Element, XMLNode};

/// Represents a generic executable configuration.
#[derive(Debug, Deserialize, Serialize)]
struct ExecutableConfig {
    /// The path to the executable.
    path: Option<String>,
    /// Optional arguments to be passed to the executable.
    args: Option<Vec<String>>,
    /// Optional environment variables for the executable.
    env: Option<HashMap<String, String>>,
}

/// The overall configuration loaded from ~/.ars_pr_cover.
#[derive(Debug, Deserialize, Serialize)]
struct Config {
    /// The default branch to compare against.
    default_branch: Option<String>,
    /// Path to the input coverage file.
    input_coverage: Option<String>,
    /// Path to the output (filtered) coverage file.
    output_coverage: Option<String>,
    /// Configuration for the Git executable.
    git: Option<ExecutableConfig>,
    /// Configuration for the coverage runner executable.
    coverage_runner: Option<ExecutableConfig>,
}

/// Command-line arguments for the application.
#[derive(Parser, Debug)]
#[command(
    name = "ars_pr_cover",
    version = "1.0",
    about = "Runs a coverage runner and filters the coverage file to include only files changed relative to a given branch"
)]
struct Args {
    /// Branch to compare against (overrides the default in config).
    branch: Option<String>,
}

/// Loads the configuration from ~/.ars_pr_cover.
/// If the file does not exist, it creates the file with empty (default) values.
fn load_config() -> Config {
    let home = home_dir().unwrap_or_else(|| {
        eprintln!("Could not determine home directory.");
        exit(1);
    });
    let config_path = home.join(".ars_pr_cover");

    // Define the default (empty) configuration.
    let default_config = Config {
        default_branch: Some(String::new()),
        input_coverage: Some(String::new()),
        output_coverage: Some(String::new()),
        git: Some(ExecutableConfig {
            path: Some(String::new()),
            args: Some(vec![]),
            env: Some(HashMap::new()),
        }),
        coverage_runner: Some(ExecutableConfig {
            path: Some(String::new()),
            args: Some(vec![]),
            env: Some(HashMap::new()),
        }),
    };

    // If the config file doesn't exist, create it.
    if !config_path.exists() {
        println!(
            "Config file not found at {}. Creating default config.",
            config_path.display()
        );
        let default_toml = toml::to_string_pretty(&default_config).unwrap_or_else(|err| {
            eprintln!("Error serializing default config: {}", err);
            exit(1);
        });
        fs::write(&config_path, default_toml).unwrap_or_else(|err| {
            eprintln!(
                "Failed to write default config to {}: {}",
                config_path.display(),
                err
            );
            exit(1);
        });
        println!(
            "Default config created at {}. Please review and modify it as needed.",
            config_path.display()
        );
        return default_config;
    }

    // Otherwise, load the configuration.
    let config_content = fs::read_to_string(&config_path).unwrap_or_else(|err| {
        eprintln!("Error reading {}: {}", config_path.display(), err);
        exit(1);
    });
    let mut config: Config = toml::from_str(&config_content).unwrap_or_else(|err| {
        eprintln!(
            "Error parsing config file {}: {}",
            config_path.display(),
            err
        );
        exit(1);
    });

    // For any missing field, ensure we have an empty value.
    if config.default_branch.is_none() {
        config.default_branch = Some(String::new());
    }
    if config.input_coverage.is_none() {
        config.input_coverage = Some(String::new());
    }
    if config.output_coverage.is_none() {
        config.output_coverage = Some(String::new());
    }
    if config.git.is_none() {
        config.git = Some(ExecutableConfig {
            path: Some(String::new()),
            args: Some(vec![]),
            env: Some(HashMap::new()),
        });
    } else {
        if config.git.as_ref().unwrap().args.is_none() {
            config.git.as_mut().unwrap().args = Some(vec![]);
        }
        if config.git.as_ref().unwrap().env.is_none() {
            config.git.as_mut().unwrap().env = Some(HashMap::new());
        }
    }
    if config.coverage_runner.is_none() {
        config.coverage_runner = Some(ExecutableConfig {
            path: Some(String::new()),
            args: Some(vec![]),
            env: Some(HashMap::new()),
        });
    } else {
        if config.coverage_runner.as_ref().unwrap().args.is_none() {
            config.coverage_runner.as_mut().unwrap().args = Some(vec![]);
        }
        if config.coverage_runner.as_ref().unwrap().env.is_none() {
            config.coverage_runner.as_mut().unwrap().env = Some(HashMap::new());
        }
    }

    config
}

/// Runs a shell command and returns its stdout as a String.
fn run_command(command: &str, args: &[&str]) -> String {
    match cmd(command, args).read() {
        Ok(output) => output,
        Err(err) => {
            eprintln!("Error running {} {:?}: {}", command, args, err);
            exit(1);
        }
    }
}

/// Uses the Git configuration to get a list of changed files between the given branch and HEAD.
/// It uses the configured Git arguments and then appends the branch and "HEAD".
fn get_changed_files(git: &ExecutableConfig, branch: &str) -> Vec<String> {
    let git_path = git.path.as_deref().unwrap_or("");
    let mut args: Vec<String> = git.args.clone().unwrap_or_default();
    args.push(branch.to_string());
    args.push("HEAD".to_string());

    // Convert Vec<String> to Vec<&str> for duct.
    let args_str: Vec<&str> = args.iter().map(String::as_str).collect();
    let output = run_command(git_path, &args_str);
    output
        .lines()
        .map(|line| line.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect()
}

/// Uses the coverage runner configuration to run the coverage command.
/// It applies the environment variables from the config.
fn run_coverage_runner(runner: &ExecutableConfig) {
    let runner_path = runner.path.as_deref().unwrap_or("");
    let args = runner.args.clone().unwrap_or_default();
    let args_str: Vec<&str> = args.iter().map(String::as_str).collect();

    println!("Running coverage runner ({})...", runner_path);
    let mut command = cmd(runner_path, &args_str);

    if let Some(env_map) = &runner.env {
        for (key, value) in env_map {
            command = command.env(key, value);
        }
    }

    if let Err(err) = command.run() {
        eprintln!("Error running coverage runner: {}", err);
        exit(1);
    }
}

/// Parses and filters the input coverage file so that only <file> nodes whose 'name'
/// attribute contains one of the changed files remain. The filtered output is saved to the given output path.
fn filter_coverage(changed_files: Vec<String>, input_path: &str, output_path: &str) {
    if !Path::new(input_path).exists() {
        eprintln!("Error: {} not found.", input_path);
        exit(1);
    }

    let coverage_content = fs::read_to_string(input_path).unwrap_or_else(|err| {
        eprintln!("Error reading {}: {}", input_path, err);
        exit(1);
    });
    let mut root = Element::parse(coverage_content.as_bytes()).unwrap_or_else(|err| {
        eprintln!("Error parsing {}: {}", input_path, err);
        exit(1);
    });

    // Expected structure:
    // <coverage>
    //   <project>
    //     <file name="..."> ... </file>
    //     ...
    //   </project>
    // </coverage>
    if let Some(project_elem) = root.get_mut_child("project") {
        let original_count = project_elem
            .children
            .iter()
            .filter(|node| matches!(node, XMLNode::Element(e) if e.name == "file"))
            .count();

        project_elem.children.retain(|node| {
            if let XMLNode::Element(ref elem) = node {
                if elem.name == "file" {
                    if let Some(file_name) = elem.attributes.get("name") {
                        // Keep this <file> element if any changed file path is a substring of file_name.
                        return changed_files
                            .iter()
                            .any(|changed| file_name.contains(changed));
                    }
                }
            }
            // Retain non-<file> nodes.
            true
        });

        let filtered_count = project_elem
            .children
            .iter()
            .filter(|node| matches!(node, XMLNode::Element(e) if e.name == "file"))
            .count();

        println!(
            "Filtered <file> nodes: {} out of {} remain.",
            filtered_count, original_count
        );
    } else {
        eprintln!("Error: No 'project' element found in {}.", input_path);
        exit(1);
    }

    let mut output_file = fs::File::create(output_path).unwrap_or_else(|err| {
        eprintln!("Error creating {}: {}", output_path, err);
        exit(1);
    });
    root.write(&mut output_file).unwrap_or_else(|err| {
        eprintln!("Error writing {}: {}", output_path, err);
        exit(1);
    });

    println!("Filtered coverage saved as {}.", output_path);
}

fn main() {
    // Parse command-line arguments.
    let args = Args::parse();
    // Load (or create) configuration from ~/.ars_pr_cover.
    let config = load_config();

    // Determine which branch to use (CLI argument takes precedence over the config default).
    let branch = args
        .branch
        .unwrap_or_else(|| config.default_branch.unwrap_or_else(|| String::new()));
    println!("Comparing current branch with '{}'", branch);

    // Get the Git configuration.
    let git_config = config.git.unwrap();
    let changed_files = get_changed_files(&git_config, &branch);

    if changed_files.is_empty() {
        println!("No changed files found relative to branch '{}'.", branch);
        exit(0);
    }

    println!("Found {} changed file(s):", changed_files.len());
    for file in &changed_files {
        println!("  - {}", file);
    }

    // Run the coverage runner using its configuration.
    let coverage_config = config.coverage_runner.unwrap();
    run_coverage_runner(&coverage_config);

    // Retrieve the input and output coverage file paths from the config.
    let input_path = config.input_coverage.unwrap_or_else(|| String::new());
    let output_path = config.output_coverage.unwrap_or_else(|| String::new());

    // Filter the coverage file using the list of changed files.
    filter_coverage(changed_files, &input_path, &output_path);
}