use super::action::identity::{ActionId, CommitSha, Version};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowAction {
pub id: ActionId,
pub version: Version,
pub sha: Option<CommitSha>,
}
#[derive(Debug, Default)]
pub struct ActionSet {
versions: HashMap<ActionId, HashSet<Version>>,
counts: HashMap<ActionId, HashMap<Version, usize>>,
}
impl ActionSet {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn from_located(actions: &[Located]) -> Self {
let mut set = Self::new();
for action in actions {
set.add(&action.action);
}
set
}
pub fn add(&mut self, interpreted: &WorkflowAction) {
self.versions
.entry(interpreted.id.clone())
.or_default()
.insert(interpreted.version.clone());
let count = self
.counts
.entry(interpreted.id.clone())
.or_default()
.entry(interpreted.version.clone())
.or_insert(0);
*count = count.saturating_add(1);
}
#[must_use]
pub fn dominant_version(&self, id: &ActionId) -> Option<Version> {
let counts = self.counts.get(id)?;
let max_count = counts.values().max().copied()?;
let candidates: Vec<Version> = counts
.iter()
.filter(|(_, c)| **c == max_count)
.map(|(v, _)| v.clone())
.collect();
Version::highest(&candidates)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.versions.is_empty()
}
pub fn versions_for(&self, id: &ActionId) -> impl Iterator<Item = &Version> {
self.versions
.get(id)
.map(|v| v.iter())
.into_iter()
.flatten()
}
pub fn action_ids(&self) -> impl Iterator<Item = &ActionId> {
self.versions.keys()
}
}
#[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)]
pub struct Location {
pub workflow: WorkflowPath,
pub job: Option<JobId>,
pub step: Option<StepIndex>,
}
#[derive(Debug, Clone)]
pub struct Located {
pub action: WorkflowAction,
pub location: Location,
}
#[cfg(test)]
mod tests {
use super::{
ActionId, ActionSet, JobId, Located, Location, StepIndex, Version, WorkflowAction,
WorkflowPath,
};
use crate::domain::action::identity::CommitSha;
fn make_interpreted(name: &str, version: &str, sha: Option<&str>) -> WorkflowAction {
WorkflowAction {
id: ActionId::from(name),
version: Version::from(version),
sha: sha.map(CommitSha::from),
}
}
#[test]
fn most_used_version_two_vs_one() {
let mut set = ActionSet::new();
set.add(&make_interpreted("actions/checkout", "v3", None));
set.add(&make_interpreted("actions/checkout", "v3", None));
set.add(&make_interpreted("actions/checkout", "v4", None));
let dominant = set.dominant_version(&ActionId::from("actions/checkout"));
assert_eq!(dominant, Some(Version::from("v3")));
}
#[test]
fn dominant_version_tiebreak_highest_semver() {
let mut set = ActionSet::new();
set.add(&make_interpreted("actions/checkout", "v3", None));
set.add(&make_interpreted("actions/checkout", "v4", None));
let dominant = set.dominant_version(&ActionId::from("actions/checkout"));
assert_eq!(dominant, Some(Version::from("v4")));
}
#[test]
fn workflow_location_equality() {
let loc1 = Location {
workflow: WorkflowPath::new(".github/workflows/ci.yml"),
job: Some(JobId::from("build")),
step: Some(StepIndex::from(0_u16)),
};
let loc2 = Location {
workflow: WorkflowPath::new(".github/workflows/ci.yml"),
job: Some(JobId::from("build")),
step: Some(StepIndex::from(0_u16)),
};
assert_eq!(loc1, loc2);
}
#[test]
fn located_action_stores_location() {
let loc = Location {
workflow: WorkflowPath::new(".github/workflows/ci.yml"),
job: Some(JobId::from("build")),
step: Some(StepIndex::from(0_u16)),
};
let action = Located {
action: WorkflowAction {
id: ActionId::from("actions/checkout"),
version: Version::from("v4"),
sha: None,
},
location: loc.clone(),
};
assert_eq!(action.location, loc);
assert_eq!(action.action.id.as_str(), "actions/checkout");
}
#[test]
fn add_single_version() {
let mut set = ActionSet::new();
set.add(&make_interpreted("actions/checkout", "v4", None));
let versions: Vec<_> = set
.versions_for(&ActionId::from("actions/checkout"))
.collect();
assert_eq!(versions.len(), 1);
assert!(versions.contains(&&Version::from("v4")));
}
#[test]
fn add_multiple_versions() {
let mut set = ActionSet::new();
set.add(&make_interpreted("actions/checkout", "v4", None));
set.add(&make_interpreted("actions/checkout", "v3", None));
let versions: Vec<_> = set
.versions_for(&ActionId::from("actions/checkout"))
.collect();
assert_eq!(versions.len(), 2);
assert!(versions.contains(&&Version::from("v4")));
assert!(versions.contains(&&Version::from("v3")));
}
#[test]
fn add_duplicate_version() {
let mut set = ActionSet::new();
set.add(&make_interpreted("actions/checkout", "v4", None));
set.add(&make_interpreted("actions/checkout", "v4", None));
assert_eq!(
set.versions_for(&ActionId::from("actions/checkout"))
.count(),
1
);
}
#[test]
fn action_ids() {
let mut set = ActionSet::new();
set.add(&make_interpreted("actions/checkout", "v4", None));
set.add(&make_interpreted("actions/setup-node", "v3", None));
let ids: Vec<_> = set.action_ids().collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&&ActionId::from("actions/checkout")));
assert!(ids.contains(&&ActionId::from("actions/setup-node")));
}
#[test]
fn versions_for_unknown_action() {
let set = ActionSet::new();
assert_eq!(
set.versions_for(&ActionId::from("unknown/action")).count(),
0
);
}
}