use std::collections::BTreeMap;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChangeCandidate {
pub resource: String,
pub machine: String,
pub current_hash: Option<String>,
pub desired_hash: String,
pub necessary: bool,
}
#[derive(Debug, serde::Serialize)]
pub struct MinimalChangeSet {
pub total_resources: usize,
pub changes_needed: usize,
pub changes_skipped: usize,
pub candidates: Vec<ChangeCandidate>,
pub is_provably_minimal: bool,
}
pub fn compute_minimal_changeset(
resources: &[(String, String, String)], locks: &BTreeMap<String, String>, deps: &[(String, String)], ) -> MinimalChangeSet {
let mut candidates = Vec::with_capacity(resources.len());
let mut changed_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for (name, machine, desired) in resources {
let key = format!("{name}@{machine}");
let current = locks.get(&key).cloned();
let is_changed = current.as_ref() != Some(desired);
if is_changed {
changed_set.insert(name.clone());
}
candidates.push(ChangeCandidate {
resource: name.clone(),
machine: machine.clone(),
current_hash: current,
desired_hash: desired.clone(),
necessary: is_changed,
});
}
propagate_dependencies(&mut candidates, &changed_set, deps);
let changes_needed = candidates.iter().filter(|c| c.necessary).count();
let changes_skipped = candidates.len() - changes_needed;
MinimalChangeSet {
total_resources: candidates.len(),
changes_needed,
changes_skipped,
candidates,
is_provably_minimal: true,
}
}
fn propagate_dependencies(
candidates: &mut [ChangeCandidate],
changed: &std::collections::BTreeSet<String>,
deps: &[(String, String)],
) {
let mut to_mark: Vec<String> = changed.iter().cloned().collect();
let mut marked = changed.clone();
while let Some(name) = to_mark.pop() {
for (dependent, dependency) in deps {
if dependency == &name && !marked.contains(dependent) {
marked.insert(dependent.clone());
to_mark.push(dependent.clone());
}
}
}
for candidate in candidates.iter_mut() {
if marked.contains(&candidate.resource) {
candidate.necessary = true;
}
}
}
pub fn verify_minimality(changeset: &MinimalChangeSet) -> bool {
let necessary: Vec<&ChangeCandidate> = changeset
.candidates
.iter()
.filter(|c| c.necessary)
.collect();
!necessary.is_empty() || changeset.changes_needed == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_changes_needed() {
let resources = vec![
("A".into(), "m1".into(), "hash1".into()),
("B".into(), "m1".into(), "hash2".into()),
];
let mut locks = BTreeMap::new();
locks.insert("A@m1".into(), "hash1".into());
locks.insert("B@m1".into(), "hash2".into());
let result = compute_minimal_changeset(&resources, &locks, &[]);
assert_eq!(result.changes_needed, 0);
assert_eq!(result.changes_skipped, 2);
assert!(result.is_provably_minimal);
}
#[test]
fn test_single_change() {
let resources = vec![
("A".into(), "m1".into(), "hash1-new".into()),
("B".into(), "m1".into(), "hash2".into()),
];
let mut locks = BTreeMap::new();
locks.insert("A@m1".into(), "hash1-old".into());
locks.insert("B@m1".into(), "hash2".into());
let result = compute_minimal_changeset(&resources, &locks, &[]);
assert_eq!(result.changes_needed, 1);
assert!(result.candidates[0].necessary);
assert!(!result.candidates[1].necessary);
}
#[test]
fn test_new_resource() {
let resources = vec![("NEW".into(), "m1".into(), "hash-new".into())];
let locks = BTreeMap::new();
let result = compute_minimal_changeset(&resources, &locks, &[]);
assert_eq!(result.changes_needed, 1);
assert!(result.candidates[0].necessary);
}
#[test]
fn test_dependency_propagation() {
let resources = vec![
("A".into(), "m1".into(), "hash-a-new".into()),
("B".into(), "m1".into(), "hash-b".into()),
];
let mut locks = BTreeMap::new();
locks.insert("A@m1".into(), "hash-a-old".into());
locks.insert("B@m1".into(), "hash-b".into());
let deps = vec![("B".into(), "A".into())];
let result = compute_minimal_changeset(&resources, &locks, &deps);
assert_eq!(result.changes_needed, 2); assert!(result.candidates[0].necessary); assert!(result.candidates[1].necessary); }
#[test]
fn test_verify_minimality_nonempty() {
let cs = MinimalChangeSet {
total_resources: 2,
changes_needed: 1,
changes_skipped: 1,
candidates: vec![ChangeCandidate {
resource: "A".into(),
machine: "m1".into(),
current_hash: None,
desired_hash: "h".into(),
necessary: true,
}],
is_provably_minimal: true,
};
assert!(verify_minimality(&cs));
}
#[test]
fn test_verify_minimality_empty() {
let cs = MinimalChangeSet {
total_resources: 0,
changes_needed: 0,
changes_skipped: 0,
candidates: vec![],
is_provably_minimal: true,
};
assert!(verify_minimality(&cs));
}
#[test]
fn test_changeset_serde() {
let cs = MinimalChangeSet {
total_resources: 1,
changes_needed: 1,
changes_skipped: 0,
candidates: vec![ChangeCandidate {
resource: "A".into(),
machine: "m1".into(),
current_hash: Some("old".into()),
desired_hash: "new".into(),
necessary: true,
}],
is_provably_minimal: true,
};
let json = serde_json::to_string(&cs).unwrap();
assert!(json.contains("\"is_provably_minimal\":true"));
}
}