use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, ensure};
use serde_yaml::{Mapping, Value};
const WORKFLOW_PREFIX: &str = ".github/workflows/";
const WORKFLOW_EXTENSIONS: [&str; 2] = ["yml", "yaml"];
pub type Workflows = BTreeMap<String, Value>;
fn workflow_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join(".github")
.join("workflows")
}
pub fn parse(name: &str, text: &str) -> Result<Value> {
serde_yaml::from_str(text).with_context(|| format!("parse {name} as YAML"))
}
fn is_workflow(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
WORKFLOW_EXTENSIONS
.iter()
.any(|accepted| extension.eq_ignore_ascii_case(accepted))
})
}
pub fn workflows() -> Result<Workflows> {
let directory = workflow_dir();
let mut found = Workflows::new();
for entry in
fs::read_dir(&directory).with_context(|| format!("read {}", directory.display()))?
{
let path = entry.context("read a workflow directory entry")?.path();
if !is_workflow(&path) {
continue;
}
let name = path
.file_name()
.and_then(|name| name.to_str())
.context("workflow file name should be UTF-8")?
.to_owned();
let text = fs::read_to_string(&path).with_context(|| format!("read {name}"))?;
let parsed = parse(&name, &text)?;
found.insert(name, parsed);
}
ensure!(
!found.is_empty(),
"no workflows found under {}",
directory.display()
);
Ok(found)
}
pub fn get<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a Value> {
mapping.get(Value::String(key.to_owned()))
}
pub fn trigger_names(workflow: &Value) -> Vec<String> {
let Some(root) = workflow.as_mapping() else {
return Vec::new();
};
let Some(on) = get(root, "on").or_else(|| root.get(Value::Bool(true))) else {
return Vec::new();
};
match on {
Value::String(name) => vec![name.clone()],
Value::Sequence(names) => names
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect(),
Value::Mapping(events) => events
.keys()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect(),
_ => Vec::new(),
}
}
pub fn trigger<'a>(workflow: &'a Value, event: &str) -> Option<&'a Value> {
let root = workflow.as_mapping()?;
get(root, "on")
.or_else(|| root.get(Value::Bool(true)))?
.as_mapping()
.and_then(|events| get(events, event))
}
pub fn starts_on_pull_request(workflow: &Value) -> bool {
trigger_names(workflow)
.iter()
.any(|name| matches!(name.as_str(), "pull_request" | "pull_request_target"))
}
pub fn jobs(workflow: &Value) -> Vec<(&str, &Mapping)> {
workflow
.as_mapping()
.and_then(|root| get(root, "jobs"))
.and_then(Value::as_mapping)
.map(|jobs| {
jobs.iter()
.filter_map(|(id, job)| Some((id.as_str()?, job.as_mapping()?)))
.collect()
})
.unwrap_or_default()
}
pub fn steps(workflow: &Value) -> Vec<&Mapping> {
jobs(workflow)
.into_iter()
.filter_map(|(_, job)| get(job, "steps").and_then(Value::as_sequence))
.flatten()
.filter_map(Value::as_mapping)
.collect()
}
pub fn uses(step: &Mapping) -> Option<&str> { get(step, "uses").and_then(Value::as_str) }
#[derive(Debug, PartialEq, Eq)]
pub enum Call<'a> {
Local(&'a str),
Refused,
Remote,
}
pub fn classify_call(reference: &str) -> Call<'_> {
let path = reference
.strip_prefix("./")
.or_else(|| reference.strip_prefix("$/"))
.unwrap_or(reference);
let Some(file) = path.strip_prefix(WORKFLOW_PREFIX) else {
return Call::Remote;
};
if file.is_empty() || file.contains('/') || file.contains('@') {
return Call::Refused;
}
Call::Local(file)
}
pub fn job_calls(workflow: &Value) -> Vec<(&str, &str)> {
jobs(workflow)
.into_iter()
.filter_map(|(id, job)| Some((id, get(job, "uses")?.as_str()?)))
.collect()
}
pub fn pull_request_closure(all: &Workflows) -> BTreeSet<String> {
let mut reached: BTreeSet<String> = all
.iter()
.filter(|(_, workflow)| starts_on_pull_request(workflow))
.map(|(name, _)| name.clone())
.collect();
let mut pending: Vec<String> = reached.iter().cloned().collect();
while let Some(name) = pending.pop() {
let Some(workflow) = all.get(&name) else {
continue;
};
for (_, reference) in job_calls(workflow) {
let Call::Local(callee) = classify_call(reference) else {
continue;
};
if all.contains_key(callee) && reached.insert(callee.to_owned()) {
pending.push(callee.to_owned());
}
}
}
reached
}
pub fn missing_callees(all: &Workflows, names: &BTreeSet<String>) -> Vec<String> {
names
.iter()
.filter_map(|name| Some((name, all.get(name)?)))
.flat_map(|(name, workflow)| {
job_calls(workflow)
.into_iter()
.filter(|(_, reference)| {
matches!(classify_call(reference), Call::Local(file) if !all.contains_key(file))
})
.map(move |(_, reference)| format!("{name} calls `{reference}`, which is not there"))
})
.collect()
}