#!/usr/bin/env rust-script
use std::env;
use std::fs;
use std::io::Write;
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 exec_silent(command: &str, args: &[&str]) {
let _ = Command::new(command)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
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 get_changed_files() -> Vec<String> {
let event_name = env::var("GITHUB_EVENT_NAME").unwrap_or_else(|_| "local".to_string());
if event_name == "pull_request" {
let base_sha = env::var("GITHUB_BASE_SHA").ok();
let head_sha = env::var("GITHUB_HEAD_SHA").ok();
if let (Some(base), Some(head)) = (base_sha, head_sha) {
println!("Comparing PR: {}...{}", base, head);
exec_silent("git", &["fetch", "origin", &base]);
let output = exec("git", &["diff", "--name-only", &base, &head]);
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 is_excluded_from_code_changes(file_path: &str) -> bool {
if file_path.ends_with(".md") {
return true;
}
let excluded_folders = [
"changelog.d/",
"dev/log/",
"docs/",
"experiments/",
"examples/",
];
for folder in &excluded_folders {
if file_path.starts_with(folder) {
return true;
}
}
false
}
fn is_manifest_or_lockfile_change(file_path: &str) -> bool {
file_path.ends_with(".toml") || file_path.ends_with("Cargo.lock")
}
#[derive(Debug, PartialEq, Eq)]
struct ChangeKinds {
rs: bool,
toml: bool,
mjs: bool,
docs: bool,
workflow: bool,
any_code: bool,
}
fn classify_changes(changed_files: &[String]) -> ChangeKinds {
let included: Vec<&String> = changed_files
.iter()
.filter(|file| !is_excluded_from_code_changes(file))
.collect();
ChangeKinds {
rs: included.iter().any(|file| file.ends_with(".rs")),
toml: included
.iter()
.any(|file| is_manifest_or_lockfile_change(file)),
mjs: included.iter().any(|file| file.ends_with(".mjs")),
docs: changed_files.iter().any(|file| file.ends_with(".md")),
workflow: included
.iter()
.any(|file| file.starts_with(".github/workflows/")),
any_code: !included.is_empty(),
}
}
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 kinds = classify_changes(&changed_files);
set_output("rs-changed", if kinds.rs { "true" } else { "false" });
set_output("toml-changed", if kinds.toml { "true" } else { "false" });
set_output("mjs-changed", if kinds.mjs { "true" } else { "false" });
set_output("docs-changed", if kinds.docs { "true" } else { "false" });
set_output(
"workflow-changed",
if kinds.workflow { "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!();
set_output(
"any-code-changed",
if kinds.any_code { "true" } else { "false" },
);
println!("\nChange detection completed.");
}
#[cfg(test)]
mod tests {
use super::*;
fn paths(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn detects_extensionless_and_non_rust_build_inputs() {
for path in [
"Cargo.lock",
"Dockerfile",
"scripts/check.sh",
"ui/package-lock.json",
"ui/src/App.jsx",
] {
assert!(
classify_changes(&paths(&[path])).any_code,
"{path} must trigger code checks"
);
}
assert!(classify_changes(&paths(&["Cargo.lock"])).toml);
}
#[test]
fn excludes_only_deliberately_non_build_inputs() {
let kinds = classify_changes(&paths(&[
"README.md",
"docs/design.md",
"dev/log/issues/184/run.json",
"changelog.d/fix.md",
"examples/demo.rs",
"experiments/repro.sh",
]));
assert!(!kinds.any_code);
assert!(kinds.docs);
assert!(!kinds.rs);
}
}