#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct WorkflowPath(String);
impl WorkflowPath {
pub fn new<S: Into<String>>(path: S) -> Self {
Self(path.into().replace('\\', "/"))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for WorkflowPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct JobId(String);
impl JobId {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for JobId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for JobId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for JobId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct StepIndex(u16);
impl StepIndex {
#[must_use]
pub fn as_u16(self) -> u16 {
self.0
}
}
impl From<u16> for StepIndex {
fn from(value: u16) -> Self {
Self(value)
}
}
impl From<StepIndex> for i64 {
fn from(value: StepIndex) -> Self {
Self::from(value.0)
}
}
impl TryFrom<i64> for StepIndex {
type Error = String;
fn try_from(value: i64) -> Result<Self, Self::Error> {
let raw = u16::try_from(value)
.map_err(|_| format!("invalid step index: {value} (must be 0..=65535)"))?;
Ok(Self(raw))
}
}
impl TryFrom<usize> for StepIndex {
type Error = String;
fn try_from(value: usize) -> Result<Self, Self::Error> {
let raw = u16::try_from(value)
.map_err(|_| format!("invalid step index: {value} (must be 0..=65535)"))?;
Ok(Self(raw))
}
}
impl std::fmt::Display for StepIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Slot {
WorkflowStep {
job: JobId,
step: StepIndex,
},
CompositeStep {
step: StepIndex,
},
}
impl Slot {
#[must_use]
pub fn job(&self) -> Option<&JobId> {
match self {
Self::WorkflowStep { job, .. } => Some(job),
Self::CompositeStep { .. } => None,
}
}
#[must_use]
pub fn step(&self) -> Option<StepIndex> {
match self {
Self::WorkflowStep { step, .. } | Self::CompositeStep { step } => Some(*step),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Id {
pub file: WorkflowPath,
pub slot: Slot,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Scope {
File,
Job {
job: JobId,
},
JobStep {
job: JobId,
step: StepIndex,
},
CompositeStep {
step: StepIndex,
},
}
impl Scope {
#[must_use]
pub fn selects(&self, slot: &Slot) -> bool {
match (self, slot) {
(Self::File, _) => true,
(Self::Job { job }, Slot::WorkflowStep { job: j, .. }) => job == j,
(Self::JobStep { job, step }, Slot::WorkflowStep { job: j, step: s }) => {
job == j && step == s
}
(Self::CompositeStep { step }, Slot::CompositeStep { step: s }) => step == s,
_ => false,
}
}
#[must_use]
pub fn job(&self) -> Option<&JobId> {
match self {
Self::Job { job } | Self::JobStep { job, .. } => Some(job),
Self::File | Self::CompositeStep { .. } => None,
}
}
#[must_use]
pub fn step(&self) -> Option<StepIndex> {
match self {
Self::JobStep { step, .. } | Self::CompositeStep { step } => Some(*step),
Self::File | Self::Job { .. } => None,
}
}
#[must_use]
pub fn precedence(&self) -> u8 {
match self {
Self::File => 0,
Self::Job { .. } => 1,
Self::JobStep { .. } | Self::CompositeStep { .. } => 2,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Origin {
pub line: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::{Id, JobId, Origin, Slot, StepIndex, WorkflowPath};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher as _};
fn hash_of<T: Hash>(value: &T) -> u64 {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
fn workflow_site() -> Id {
Id {
file: WorkflowPath::new(".github/workflows/ci.yml"),
slot: Slot::WorkflowStep {
job: JobId::from("build"),
step: StepIndex::from(0),
},
}
}
#[test]
fn identity_is_independent_of_origin() {
let (a, b) = (workflow_site(), workflow_site());
let (origin_a, origin_b) = (Origin { line: Some(12) }, Origin { line: Some(40) });
assert_eq!(a, b, "same site must compare equal");
assert_eq!(hash_of(&a), hash_of(&b), "same site must hash equal");
assert_ne!(
origin_a, origin_b,
"differing provenance is still observable, just not part of identity"
);
}
#[test]
fn sites_in_different_files_are_distinct() {
let mut other = workflow_site();
other.file = WorkflowPath::new(".github/workflows/release.yml");
assert_ne!(workflow_site(), other);
}
#[test]
fn composite_step_is_distinct_from_workflow_step() {
let composite = Id {
file: WorkflowPath::new(".github/actions/setup/action.yml"),
slot: Slot::CompositeStep {
step: StepIndex::from(0),
},
};
let workflow = Id {
file: WorkflowPath::new(".github/actions/setup/action.yml"),
slot: Slot::WorkflowStep {
job: JobId::from("build"),
step: StepIndex::from(0),
},
};
assert_ne!(composite, workflow);
}
#[test]
fn only_a_workflow_slot_names_a_job() {
let workflow = Slot::WorkflowStep {
job: JobId::from("release"),
step: StepIndex::from(0),
};
let composite = Slot::CompositeStep {
step: StepIndex::from(0),
};
assert_eq!(workflow.job().map(JobId::as_str), Some("release"));
assert_eq!(composite.job(), None);
}
#[test]
fn origin_defaults_to_no_line() {
assert_eq!(Origin::default().line, None);
}
}