#!/usr/bin/env rust-script
use regex::Regex;
use std::env;
use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::Command;
fn exec(command: &str, args: &[&str]) -> String {
match Command::new(command).args(args).output() {
Ok(output) => {
if output.status.success() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
eprintln!("Error executing {} {:?}", command, args);
eprintln!("{}", String::from_utf8_lossy(&output.stderr));
String::new()
}
}
Err(e) => {
eprintln!("Failed to execute {} {:?}: {}", command, args, e);
String::new()
}
}
}
fn set_output(name: &str, value: &str) {
if let Ok(output_file) = env::var("GITHUB_OUTPUT") {
if let Ok(mut file) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&output_file)
{
let _ = writeln!(file, "{}={}", name, value);
}
}
println!("{}={}", name, value);
}
fn is_merge_commit() -> bool {
let output = exec("git", &["cat-file", "-p", "HEAD"]);
output
.lines()
.filter(|line| line.starts_with("parent "))
.count()
> 1
}
fn get_changed_files() -> Vec<String> {
if is_merge_commit() {
println!("Merge commit detected (pull_request event)");
println!("Comparing HEAD^2^ to HEAD^2 (per-commit diff of PR head)");
let output = exec("git", &["diff", "--name-only", "HEAD^2^", "HEAD^2"]);
if !output.is_empty() {
return output
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
}
println!("HEAD^2^ not available (first commit in PR), comparing HEAD^ to HEAD^2");
let output = exec("git", &["diff", "--name-only", "HEAD^", "HEAD^2"]);
if !output.is_empty() {
return output
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
}
}
println!("Comparing HEAD^ to HEAD");
let output = exec("git", &["diff", "--name-only", "HEAD^", "HEAD"]);
if output.is_empty() {
println!("HEAD^ not available, listing all files in HEAD");
let output = exec("git", &["ls-tree", "--name-only", "-r", "HEAD"]);
return output
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
}
output
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
}
fn get_rust_root() -> String {
let args: Vec<String> = env::args().collect();
if let Some(idx) = args.iter().position(|arg| arg == "--rust-root") {
if let Some(root) = args.get(idx + 1) {
return root.clone();
}
}
if let Ok(root) = env::var("RUST_ROOT") {
if !root.is_empty() {
return root;
}
}
if Path::new("./Cargo.toml").exists() {
return ".".to_string();
}
if Path::new("./rust/Cargo.toml").exists() {
return "rust".to_string();
}
".".to_string()
}
fn rust_prefix(rust_root: &str) -> String {
if rust_root == "." {
String::new()
} else {
format!("{rust_root}/")
}
}
fn is_rust_workflow(file_path: &str) -> bool {
matches!(
file_path,
".github/workflows/rust.yml" | ".github/workflows/parity.yml"
)
}
fn is_rust_file(file_path: &str, rust_root: &str) -> bool {
file_path.starts_with(&rust_prefix(rust_root)) || is_rust_workflow(file_path)
}
fn is_rust_script(file_path: &str, rust_root: &str) -> bool {
let prefix = rust_prefix(rust_root);
file_path.starts_with(&format!("{prefix}scripts/"))
}
fn is_excluded_from_code_changes(file_path: &str, rust_root: &str) -> bool {
if file_path.ends_with(".md") {
return true;
}
let prefix = rust_prefix(rust_root);
let excluded_folders = [
format!("{prefix}changelog.d/"),
format!("{prefix}docs/"),
format!("{prefix}experiments/"),
format!("{prefix}examples/"),
];
for folder in excluded_folders {
if file_path.starts_with(&folder) {
return true;
}
}
false
}
fn main() {
println!("Detecting file changes for CI/CD...\n");
let rust_root = get_rust_root();
let prefix = rust_prefix(&rust_root);
println!("Rust root: {rust_root}");
let changed_files = get_changed_files();
println!("Changed files:");
if changed_files.is_empty() {
println!(" (none)");
} else {
for file in &changed_files {
println!(" {}", file);
}
}
println!();
let rust_changed = changed_files
.iter()
.any(|file| is_rust_file(file, &rust_root));
set_output("rust-changed", if rust_changed { "true" } else { "false" });
let rust_scripts_changed = changed_files
.iter()
.any(|file| is_rust_script(file, &rust_root));
set_output(
"rust-scripts-changed",
if rust_scripts_changed {
"true"
} else {
"false"
},
);
let rust_workflow_changed = changed_files.iter().any(|file| is_rust_workflow(file));
set_output(
"rust-workflow-changed",
if rust_workflow_changed {
"true"
} else {
"false"
},
);
let rs_changed = changed_files
.iter()
.any(|f| f.starts_with(&prefix) && f.ends_with(".rs"));
set_output("rs-changed", if rs_changed { "true" } else { "false" });
let toml_changed = changed_files
.iter()
.any(|f| f.starts_with(&prefix) && f.ends_with(".toml"));
set_output("toml-changed", if toml_changed { "true" } else { "false" });
let mjs_changed = changed_files.iter().any(|f| f.ends_with(".mjs"));
set_output("mjs-changed", if mjs_changed { "true" } else { "false" });
let docs_changed = changed_files.iter().any(|f| f.ends_with(".md"));
set_output("docs-changed", if docs_changed { "true" } else { "false" });
let workflow_changed = changed_files
.iter()
.any(|f| f.starts_with(".github/workflows/"));
set_output(
"workflow-changed",
if workflow_changed { "true" } else { "false" },
);
let code_changed_files: Vec<&String> = changed_files
.iter()
.filter(|f| is_rust_file(f, &rust_root))
.filter(|f| !is_excluded_from_code_changes(f, &rust_root))
.collect();
println!("\nFiles considered as code changes:");
if code_changed_files.is_empty() {
println!(" (none)");
} else {
for file in &code_changed_files {
println!(" {}", file);
}
}
println!();
let code_pattern = Regex::new(r"\.(rs|toml|lock|yml|yaml)$|\.github/workflows/").unwrap();
let code_changed = code_changed_files.iter().any(|f| code_pattern.is_match(f));
set_output(
"rust-code-changed",
if code_changed { "true" } else { "false" },
);
set_output(
"any-rust-code-changed",
if code_changed { "true" } else { "false" },
);
set_output(
"any-code-changed",
if code_changed { "true" } else { "false" },
);
println!("\nChange detection completed.");
}