pub mod overrides;
use super::action::identity::ActionId;
use super::action::spec::Spec;
use super::action::specifier::Specifier;
use super::diff::ManifestDiff;
use super::workflow_actions::{ActionSet, Located, Location};
use overrides::ActionOverride;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Default, Clone)]
pub struct Manifest {
actions: HashMap<ActionId, Spec>,
overrides: HashMap<ActionId, Vec<ActionOverride>>,
}
impl Manifest {
#[must_use]
pub fn new(actions: HashMap<ActionId, Spec>) -> Self {
Self {
actions,
overrides: HashMap::new(),
}
}
#[must_use]
pub fn with_overrides(
actions: HashMap<ActionId, Spec>,
new_overrides: HashMap<ActionId, Vec<ActionOverride>>,
) -> Self {
Self {
actions,
overrides: new_overrides,
}
}
#[must_use]
pub fn get(&self, id: &ActionId) -> Option<&Specifier> {
self.actions.get(id).map(|s| &s.specifier)
}
#[must_use]
pub fn resolve_version(&self, id: &ActionId, location: &Location) -> Option<&Specifier> {
if let Some(ovrs) = self.overrides.get(id)
&& let Some(v) = overrides::resolve_version(ovrs, location)
{
return Some(v);
}
self.get(id)
}
pub fn set(&mut self, id: ActionId, version: Specifier) {
let spec = Spec::new(id.clone(), version);
self.actions.insert(id, spec);
}
pub fn add_override(&mut self, id: ActionId, action_override: ActionOverride) {
self.overrides.entry(id).or_default().push(action_override);
}
#[must_use]
pub fn overrides_for(&self, id: &ActionId) -> &[ActionOverride] {
self.overrides.get(id).map_or(&[], Vec::as_slice)
}
pub fn remove(&mut self, id: &ActionId) {
self.actions.remove(id);
self.overrides.remove(id);
}
#[must_use]
pub fn has(&self, id: &ActionId) -> bool {
self.actions.contains_key(id)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.actions.is_empty()
}
pub fn specs(&self) -> impl Iterator<Item = &Spec> {
self.actions.values()
}
#[must_use]
pub fn all_overrides(&self) -> &HashMap<ActionId, Vec<ActionOverride>> {
&self.overrides
}
pub fn replace_overrides(&mut self, id: ActionId, new_overrides: Vec<ActionOverride>) {
if new_overrides.is_empty() {
self.overrides.remove(&id);
} else {
self.overrides.insert(id, new_overrides);
}
}
pub fn sync_overrides(&mut self, located: &[Located], action_set: &ActionSet) {
overrides::sync(&mut self.overrides, &self.actions, located, action_set);
}
pub fn prune_stale_overrides(&mut self, located: &[Located]) {
overrides::prune_stale(&mut self.overrides, located);
}
#[must_use]
pub fn lock_keys(&self) -> Vec<Spec> {
let seen: HashSet<Spec> = self
.specs()
.cloned()
.chain(
self.all_overrides()
.iter()
.flat_map(|(id, ovrs)| overrides::override_lock_keys(id, ovrs)),
)
.collect();
seen.into_iter().collect()
}
#[must_use]
pub fn diff(&self, other: &Manifest) -> ManifestDiff {
let before_ids: HashSet<ActionId> = self.specs().map(|s| s.id.clone()).collect();
let after_ids: HashSet<ActionId> = other.specs().map(|s| s.id.clone()).collect();
let added: Vec<(ActionId, Specifier)> = after_ids
.difference(&before_ids)
.filter_map(|id| other.get(id).map(|v| (id.clone(), v.clone())))
.collect();
let removed: Vec<ActionId> = before_ids.difference(&after_ids).cloned().collect();
let updated: Vec<(ActionId, Specifier)> = before_ids
.intersection(&after_ids)
.filter_map(|id| {
let bv = self.get(id)?;
let av = other.get(id)?;
(bv != av).then(|| (id.clone(), av.clone()))
})
.collect();
let before_overrides = self.all_overrides();
let after_overrides = other.all_overrides();
let mut overrides_added = Vec::new();
let mut overrides_removed = Vec::new();
for (id, after_list) in after_overrides {
let before_list = before_overrides.get(id).cloned().unwrap_or_default();
for ovr in after_list {
if !before_list.contains(ovr) {
overrides_added.push((id.clone(), ovr.clone()));
}
}
}
for (id, before_list) in before_overrides {
let after_list = after_overrides.get(id).cloned().unwrap_or_default();
let removed_for_id: Vec<ActionOverride> = before_list
.iter()
.filter(|ovr| !after_list.contains(ovr))
.cloned()
.collect();
if !removed_for_id.is_empty() {
overrides_removed.push((id.clone(), removed_for_id));
}
}
ManifestDiff {
added,
removed,
updated,
overrides_added,
overrides_removed,
}
}
}
#[cfg(test)]
#[expect(
clippy::indexing_slicing,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
mod tests {
use super::{ActionId, ActionOverride, Manifest, Specifier};
use crate::domain::workflow_actions::{JobId, Location, StepIndex, WorkflowPath};
fn make_loc(workflow: &str, job: Option<&str>, step: Option<u16>) -> Location {
Location {
workflow: WorkflowPath::new(workflow),
job: job.map(JobId::from),
step: step.map(StepIndex::from),
}
}
#[test]
fn set_and_get() {
let mut m = Manifest::default();
m.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
assert_eq!(
m.get(&ActionId::from("actions/checkout")),
Some(&Specifier::parse("^4"))
);
}
#[test]
fn has_and_remove() {
let mut m = Manifest::default();
m.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
assert!(m.has(&ActionId::from("actions/checkout")));
m.remove(&ActionId::from("actions/checkout"));
assert!(!m.has(&ActionId::from("actions/checkout")));
}
#[test]
fn remove_also_clears_overrides() {
let mut m = Manifest::default();
m.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
m.add_override(
ActionId::from("actions/checkout"),
ActionOverride {
workflow: WorkflowPath::new(".github/workflows/ci.yml"),
job: None,
step: None,
version: Specifier::parse("^3"),
},
);
m.remove(&ActionId::from("actions/checkout"));
assert!(
m.overrides_for(&ActionId::from("actions/checkout"))
.is_empty()
);
}
#[test]
fn is_empty() {
let mut m = Manifest::default();
assert!(m.is_empty());
m.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
assert!(!m.is_empty());
}
#[test]
fn specs() {
let mut m = Manifest::default();
m.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
m.set(ActionId::from("actions/setup-node"), Specifier::parse("^3"));
assert_eq!(m.specs().count(), 2);
}
#[test]
fn resolve_version_returns_global_when_no_override() {
let mut m = Manifest::default();
m.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
let loc = make_loc(".github/workflows/ci.yml", Some("build"), Some(0));
assert_eq!(
m.resolve_version(&ActionId::from("actions/checkout"), &loc),
Some(&Specifier::parse("^4"))
);
}
#[test]
fn resolve_version_returns_none_when_not_in_manifest() {
let m = Manifest::default();
assert_eq!(
m.resolve_version(
&ActionId::from("actions/checkout"),
&make_loc(".github/workflows/ci.yml", None, None)
),
None
);
}
#[test]
fn diff_empty_manifests_is_empty() {
let before = Manifest::default();
let after = Manifest::default();
assert!(before.diff(&after).is_empty());
}
#[test]
fn diff_detects_added_action() {
let before = Manifest::default();
let mut after = Manifest::default();
after.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
let diff = before.diff(&after);
assert_eq!(diff.added.len(), 1);
assert_eq!(diff.added[0].0, ActionId::from("actions/checkout"));
assert_eq!(diff.added[0].1, Specifier::parse("^4"));
assert!(diff.removed.is_empty());
assert!(diff.updated.is_empty());
}
#[test]
fn diff_detects_removed_action() {
let mut before = Manifest::default();
before.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
let after = Manifest::default();
let diff = before.diff(&after);
assert!(diff.added.is_empty());
assert_eq!(diff.removed.len(), 1);
assert_eq!(diff.removed[0], ActionId::from("actions/checkout"));
assert!(diff.updated.is_empty());
}
#[test]
fn diff_detects_updated_action() {
let mut before = Manifest::default();
before.set(ActionId::from("actions/checkout"), Specifier::parse("^3"));
let mut after = Manifest::default();
after.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
let diff = before.diff(&after);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
assert_eq!(diff.updated.len(), 1);
assert_eq!(diff.updated[0].0, ActionId::from("actions/checkout"));
assert_eq!(diff.updated[0].1, Specifier::parse("^4"));
}
#[test]
fn diff_unchanged_action_not_in_diff() {
let mut before = Manifest::default();
before.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
let after = before.clone();
let diff = before.diff(&after);
assert!(diff.is_empty());
}
#[test]
fn diff_detects_override_added() {
let mut before = Manifest::default();
before.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
let mut after = before.clone();
after.add_override(
ActionId::from("actions/checkout"),
ActionOverride {
workflow: WorkflowPath::new(".github/workflows/ci.yml"),
job: None,
step: None,
version: Specifier::parse("^3"),
},
);
let diff = before.diff(&after);
assert_eq!(diff.overrides_added.len(), 1);
assert!(diff.overrides_removed.is_empty());
}
#[test]
fn diff_detects_override_removed() {
let mut before = Manifest::default();
before.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
before.add_override(
ActionId::from("actions/checkout"),
ActionOverride {
workflow: WorkflowPath::new(".github/workflows/ci.yml"),
job: None,
step: None,
version: Specifier::parse("^3"),
},
);
let mut after = Manifest::default();
after.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
let diff = before.diff(&after);
assert!(diff.overrides_added.is_empty());
assert_eq!(diff.overrides_removed.len(), 1);
}
#[test]
fn lock_keys_returns_global_keys() {
let mut m = Manifest::default();
m.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
m.set(ActionId::from("actions/setup-node"), Specifier::parse("^3"));
let keys = m.lock_keys();
assert_eq!(keys.len(), 2);
}
#[test]
fn lock_keys_includes_override_versions() {
let mut m = Manifest::default();
m.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
m.add_override(
ActionId::from("actions/checkout"),
ActionOverride {
workflow: WorkflowPath::new(".github/workflows/windows.yml"),
job: None,
step: None,
version: Specifier::parse("^3"),
},
);
let keys = m.lock_keys();
assert_eq!(keys.len(), 2, "should have keys for ^4 and ^3");
}
#[test]
fn lock_keys_deduplicates() {
let mut m = Manifest::default();
m.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
m.add_override(
ActionId::from("actions/checkout"),
ActionOverride {
workflow: WorkflowPath::new(".github/workflows/ci.yml"),
job: None,
step: None,
version: Specifier::parse("^3"),
},
);
m.add_override(
ActionId::from("actions/checkout"),
ActionOverride {
workflow: WorkflowPath::new(".github/workflows/deploy.yml"),
job: None,
step: None,
version: Specifier::parse("^3"),
},
);
let keys = m.lock_keys();
assert_eq!(
keys.len(),
2,
"^4 and ^3 — duplicated ^3 overrides deduplicated"
);
}
}