#!/usr/bin/env rust-script
use regex::Regex;
use std::env;
use std::fs;
use std::io::Write;
use std::process::{Command, exit};
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));
exit(1)
}
}
Err(e) => {
eprintln!("Failed to execute {} {:?}: {}", command, args, e);
exit(1)
}
}
}
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 has_ref(reference: &str) -> bool {
Command::new("git")
.args(["rev-parse", "--verify", "--quiet", reference])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn get_changed_files() -> Vec<String> {
let output = if is_merge_commit() {
if has_ref("HEAD^2^") {
exec(
"git",
&["diff", "--name-only", "--no-renames", "HEAD^2^", "HEAD^2"],
)
} else {
exec(
"git",
&["diff", "--name-only", "--no-renames", "HEAD^", "HEAD^2"],
)
}
} else if has_ref("HEAD^") {
exec(
"git",
&["diff", "--name-only", "--no-renames", "HEAD^", "HEAD"],
)
} else {
exec("git", &["ls-tree", "--name-only", "-r", "HEAD"])
};
output
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
}
fn is_excluded_from_code_changes(file_path: &str) -> bool {
if file_path.ends_with(".md") {
return true;
}
let excluded_folders = ["changelog.d/", "docs/", "experiments/", "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 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 rs_changed = changed_files.iter().any(|f| f.ends_with(".rs"));
set_output("rs-changed", if rs_changed { "true" } else { "false" });
let toml_changed = changed_files.iter().any(|f| 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_excluded_from_code_changes(f))
.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|mjs|js|yml|yaml)$|\.github/workflows/").unwrap();
let code_changed = code_changed_files.iter().any(|f| code_pattern.is_match(f));
set_output(
"any-code-changed",
if code_changed { "true" } else { "false" },
);
println!("\nChange detection completed.");
}