use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::desired_state::{
BlobRef, Canonical, Checksum, DesiredState, ResourceBody, ResourceId, ResourceKind,
ResourceScope, ResourceVersion, ValidationError,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeKind {
Added,
Removed,
Updated,
}
impl ChangeKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Added => "added",
Self::Removed => "removed",
Self::Updated => "updated",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ScopeView {
pub kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
}
impl ScopeView {
pub fn of(scope: &ResourceScope) -> Self {
match scope {
ResourceScope::Deployment => Self {
kind: "deployment",
tenant: None,
project: None,
},
ResourceScope::Tenant(tenant) => Self {
kind: "tenant",
tenant: Some(tenant.to_string()),
project: None,
},
ResourceScope::Project { tenant, project } => Self {
kind: "project",
tenant: Some(tenant.to_string()),
project: Some(project.to_string()),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BodyView {
pub form: &'static str,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub blob_kind: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
}
impl BodyView {
fn of(body: &ResourceBody) -> Result<Self, ValidationError> {
let content = body.canonical().checksum()?;
Ok(match body {
ResourceBody::Inline(_) => Self {
form: "inline",
content: content.to_string(),
blob_kind: None,
digest: None,
size_bytes: None,
},
ResourceBody::Blob(blob) => Self {
form: "blob",
content: content.to_string(),
blob_kind: Some(blob.kind.as_str()),
digest: Some(blob.digest.to_string()),
size_bytes: Some(blob.size_bytes),
},
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResourceDelta {
pub change: &'static str,
pub kind: &'static str,
pub resource: String,
pub scope: ScopeView,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_scope: Option<ScopeView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_version: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<BodyView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_body: Option<BodyView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub depends_on: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_depends_on: Option<Vec<String>>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub renamed: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub rewired: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub moved: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BlobDelta {
pub change: &'static str,
pub kind: &'static str,
pub digest: String,
pub size_bytes: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
pub struct DiffSummary {
pub added: usize,
pub removed: usize,
pub updated: usize,
pub unchanged: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SemanticDiff {
pub summary: DiffSummary,
pub resources: Vec<ResourceDelta>,
pub blobs: Vec<BlobDelta>,
}
impl SemanticDiff {
pub fn between(
previous: Option<&DesiredState>,
candidate: &DesiredState,
) -> Result<Self, ValidationError> {
let before = by_resource(previous);
let after = by_resource(Some(candidate));
let mut summary = DiffSummary::default();
let mut resources = Vec::new();
let keys: BTreeSet<(ResourceKind, ResourceId)> =
before.keys().chain(after.keys()).copied().collect();
for key in keys {
match (before.get(&key), after.get(&key)) {
(Some(old), Some(new)) => {
if old.content_checksum()? == new.content_checksum()? {
summary.unchanged += 1;
} else {
summary.updated += 1;
resources.push(updated(old, new)?);
}
}
(None, Some(new)) => {
summary.added += 1;
resources.push(added(new)?);
}
(Some(old), None) => {
summary.removed += 1;
resources.push(removed(old)?);
}
(None, None) => unreachable!("the key came from one of the two maps"),
}
}
let previous_blobs = blobs(previous);
let candidate_blobs = blobs(Some(candidate));
let mut blob_deltas: Vec<BlobDelta> = previous_blobs
.iter()
.filter(|(digest, _)| !candidate_blobs.contains_key(*digest))
.map(|(_, blob)| blob_delta(ChangeKind::Removed, blob))
.chain(
candidate_blobs
.iter()
.filter(|(digest, _)| !previous_blobs.contains_key(*digest))
.map(|(_, blob)| blob_delta(ChangeKind::Added, blob)),
)
.collect();
blob_deltas
.sort_by(|left, right| (left.change, &left.digest).cmp(&(right.change, &right.digest)));
Ok(Self {
summary,
resources,
blobs: blob_deltas,
})
}
pub fn is_empty(&self) -> bool {
self.resources.is_empty() && self.blobs.is_empty()
}
}
fn by_resource(
state: Option<&DesiredState>,
) -> BTreeMap<(ResourceKind, ResourceId), &ResourceVersion> {
let mut map: BTreeMap<(ResourceKind, ResourceId), &ResourceVersion> = BTreeMap::new();
for resource in state.into_iter().flat_map(DesiredState::resources) {
map.entry((resource.reference.kind, resource.reference.id))
.and_modify(|existing| {
if existing.reference.version < resource.reference.version {
*existing = resource;
}
})
.or_insert(resource);
}
map
}
fn blobs(state: Option<&DesiredState>) -> BTreeMap<Checksum, BlobRef> {
state
.into_iter()
.flat_map(DesiredState::blobs)
.map(|blob| (blob.digest, *blob))
.collect()
}
fn blob_delta(change: ChangeKind, blob: &BlobRef) -> BlobDelta {
BlobDelta {
change: change.as_str(),
kind: blob.kind.as_str(),
digest: blob.digest.to_string(),
size_bytes: blob.size_bytes,
}
}
fn references(resource: &ResourceVersion) -> Vec<String> {
resource
.depends_on
.iter()
.map(ToString::to_string)
.collect()
}
fn added(resource: &ResourceVersion) -> Result<ResourceDelta, ValidationError> {
Ok(ResourceDelta {
change: ChangeKind::Added.as_str(),
kind: resource.reference.kind.as_str(),
resource: resource.reference.id.to_string(),
scope: ScopeView::of(&resource.scope),
previous_scope: None,
slug: Some(resource.slug.as_str().to_owned()),
previous_slug: None,
version: Some(resource.reference.version.get()),
previous_version: None,
body: Some(BodyView::of(&resource.body)?),
previous_body: None,
depends_on: Some(references(resource)),
previous_depends_on: None,
renamed: false,
rewired: false,
moved: false,
})
}
fn removed(resource: &ResourceVersion) -> Result<ResourceDelta, ValidationError> {
Ok(ResourceDelta {
change: ChangeKind::Removed.as_str(),
kind: resource.reference.kind.as_str(),
resource: resource.reference.id.to_string(),
scope: ScopeView::of(&resource.scope),
previous_scope: None,
slug: None,
previous_slug: Some(resource.slug.as_str().to_owned()),
version: None,
previous_version: Some(resource.reference.version.get()),
body: None,
previous_body: Some(BodyView::of(&resource.body)?),
depends_on: None,
previous_depends_on: Some(references(resource)),
renamed: false,
rewired: false,
moved: false,
})
}
fn updated(old: &ResourceVersion, new: &ResourceVersion) -> Result<ResourceDelta, ValidationError> {
Ok(ResourceDelta {
change: ChangeKind::Updated.as_str(),
kind: new.reference.kind.as_str(),
resource: new.reference.id.to_string(),
scope: ScopeView::of(&new.scope),
previous_scope: Some(ScopeView::of(&old.scope)),
slug: Some(new.slug.as_str().to_owned()),
previous_slug: Some(old.slug.as_str().to_owned()),
version: Some(new.reference.version.get()),
previous_version: Some(old.reference.version.get()),
body: Some(BodyView::of(&new.body)?),
previous_body: Some(BodyView::of(&old.body)?),
depends_on: Some(references(new)),
previous_depends_on: Some(references(old)),
renamed: old.slug != new.slug,
rewired: old.depends_on != new.depends_on,
moved: old.scope != new.scope,
})
}