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};
#[derive(Debug, Deserialize, Serialize)]
struct ExecutableConfig {
path: Option<String>,
args: Option<Vec<String>>,
env: Option<HashMap<String, String>>,
}
#[derive(Debug, Deserialize, Serialize)]
struct Config {
default_branch: Option<String>,
input_coverage: Option<String>,
output_coverage: Option<String>,
git: Option<ExecutableConfig>,
coverage_runner: Option<ExecutableConfig>,
}
#[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: Option<String>,
}
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");
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 !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;
}
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);
});
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
}
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);
}
}
}
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());
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()
}
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);
}
}
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);
});
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") {
return changed_files
.iter()
.any(|changed| file_name.contains(changed));
}
}
}
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() {
let args = Args::parse();
let config = load_config();
let branch = args
.branch
.unwrap_or_else(|| config.default_branch.unwrap_or_else(|| String::new()));
println!("Comparing current branch with '{}'", branch);
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);
}
let coverage_config = config.coverage_runner.unwrap();
run_coverage_runner(&coverage_config);
let input_path = config.input_coverage.unwrap_or_else(|| String::new());
let output_path = config.output_coverage.unwrap_or_else(|| String::new());
filter_coverage(changed_files, &input_path, &output_path);
}