use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::model::{Node, Pool, Profile, Strength, is_variant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Warning,
Breaking,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Breaking => "breaking",
}
}
}
pub const RULES: &[(&str, Severity, &str)] = &[
(
"profile-removed",
Severity::Breaking,
"A profile or extension definition was removed",
),
(
"profile-added",
Severity::Info,
"A profile or extension definition was added",
),
(
"base-changed",
Severity::Warning,
"The profile is based on a different definition",
),
(
"min-increased",
Severity::Breaking,
"The minimum cardinality went up",
),
(
"min-decreased",
Severity::Info,
"The minimum cardinality went down",
),
(
"max-decreased",
Severity::Breaking,
"The maximum cardinality went down",
),
(
"max-increased",
Severity::Info,
"The maximum cardinality went up",
),
(
"type-removed",
Severity::Breaking,
"A type the element allowed is no longer allowed",
),
(
"type-added",
Severity::Info,
"The element allows another type",
),
(
"target-removed",
Severity::Breaking,
"A reference target type is no longer allowed",
),
(
"target-narrowed",
Severity::Warning,
"A reference target was narrowed to a profile of the same type",
),
(
"target-added",
Severity::Info,
"A reference may point to another profile",
),
(
"must-support-added",
Severity::Breaking,
"The element became mustSupport",
),
(
"must-support-removed",
Severity::Info,
"The element is no longer mustSupport",
),
(
"binding-stronger",
Severity::Breaking,
"The binding became extensible or required, or required from extensible",
),
(
"binding-stronger-advisory",
Severity::Info,
"The binding strength went up but stays advisory (example → preferred)",
),
(
"binding-weaker",
Severity::Info,
"The binding strength went down",
),
(
"binding-added",
Severity::Breaking,
"A required or extensible binding was added",
),
(
"value-set-changed",
Severity::Warning,
"A required or extensible binding names another value set",
),
(
"invariant-added",
Severity::Breaking,
"An error invariant was added",
),
(
"invariant-warning-added",
Severity::Warning,
"A warning invariant was added",
),
(
"invariant-changed",
Severity::Warning,
"An invariant's expression changed",
),
(
"invariant-removed",
Severity::Info,
"An invariant was removed",
),
(
"fixed-added",
Severity::Breaking,
"A fixed or pattern value was added",
),
(
"fixed-changed",
Severity::Breaking,
"The fixed or pattern value changed",
),
(
"fixed-removed",
Severity::Info,
"The fixed or pattern value was removed",
),
(
"slicing-closed",
Severity::Breaking,
"Slicing became closed",
),
("slicing-opened", Severity::Info, "Slicing became open"),
("slice-added", Severity::Info, "An optional slice was added"),
("slice-removed", Severity::Warning, "A slice was removed"),
(
"modifier-changed",
Severity::Warning,
"The isModifier flag changed",
),
];
pub fn severity_of(rule: &str) -> Severity {
RULES
.iter()
.find(|(id, _, _)| *id == rule)
.map_or(Severity::Info, |(_, severity, _)| *severity)
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Change {
pub rule: &'static str,
pub severity: Severity,
pub path: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub old: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Added,
Removed,
Changed,
Unchanged,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProfileDiff {
pub url: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub type_name: String,
pub status: Status,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_version: Option<String>,
pub file: String,
pub changes: Vec<Change>,
}
impl ProfileDiff {
pub fn worst(&self) -> Option<Severity> {
self.changes.iter().map(|c| c.severity).max()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub old: String,
pub new: String,
pub profiles: Vec<ProfileDiff>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
impl Report {
pub fn count(&self, severity: Severity) -> usize {
self.profiles
.iter()
.flat_map(|p| &p.changes)
.filter(|c| c.severity == severity)
.count()
}
pub fn worst(&self) -> Option<Severity> {
self.profiles.iter().filter_map(ProfileDiff::worst).max()
}
}
#[derive(Debug, Clone, Default)]
pub struct Filter {
pub profiles: Vec<String>,
}
impl Filter {
fn admits(&self, profile: &Profile) -> bool {
self.profiles.is_empty()
|| self
.profiles
.iter()
.any(|p| p == &profile.url || p == &profile.name)
}
}
pub fn diff(old_id: &str, old: &Pool, new_id: &str, new: &Pool, filter: &Filter) -> Report {
let (old_all, new_all) = (old.profiles(), new.profiles());
let chosen: BTreeSet<String> = old_all
.iter()
.chain(&new_all)
.filter(|p| filter.admits(p))
.map(|p| p.url.clone())
.collect();
let keep = |profiles: Vec<Profile>| -> BTreeMap<String, Profile> {
profiles
.into_iter()
.filter(|p| chosen.contains(&p.url))
.map(|p| (p.url.clone(), p))
.collect()
};
let (old_profiles, new_profiles) = (keep(old_all), keep(new_all));
let urls: BTreeSet<&String> = old_profiles.keys().chain(new_profiles.keys()).collect();
let mut profiles = Vec::new();
for url in urls {
let entry = match (old_profiles.get(url), new_profiles.get(url)) {
(Some(o), Some(n)) => {
let mut changes = Vec::new();
if o.base != n.base {
changes.push(change(
"base-changed",
&n.type_name,
"The profile is based on another definition",
o.base.clone(),
n.base.clone(),
));
}
let mut walker = Walker {
old,
new,
changes: &mut changes,
};
walker.node(&n.type_name, &n.type_name, &o.tree, &n.tree);
ProfileDiff {
url: url.clone(),
name: n.name.clone(),
title: n.title.clone(),
type_name: n.type_name.clone(),
status: if changes.is_empty() {
Status::Unchanged
} else {
Status::Changed
},
old_version: o.version.clone(),
new_version: n.version.clone(),
file: n.file.clone(),
changes,
}
}
(Some(o), None) => ProfileDiff {
url: url.clone(),
name: o.name.clone(),
title: o.title.clone(),
type_name: o.type_name.clone(),
status: Status::Removed,
old_version: o.version.clone(),
new_version: None,
file: o.file.clone(),
changes: vec![change(
"profile-removed",
&o.type_name,
"The profile is not in the new package",
Some(url.clone()),
None,
)],
},
(None, Some(n)) => ProfileDiff {
url: url.clone(),
name: n.name.clone(),
title: n.title.clone(),
type_name: n.type_name.clone(),
status: Status::Added,
old_version: None,
new_version: n.version.clone(),
file: n.file.clone(),
changes: vec![change(
"profile-added",
&n.type_name,
"The profile is new",
None,
Some(url.clone()),
)],
},
(None, None) => continue,
};
profiles.push(entry);
}
let notes = old
.skipped
.iter()
.map(|(file, reason)| format!("{old_id}: {file}: {reason}"))
.chain(
new.skipped
.iter()
.map(|(file, reason)| format!("{new_id}: {file}: {reason}")),
)
.chain(old.missing.borrow().iter().map(|base| {
format!("{old_id}: base {base} is in no loaded package; compared over the core type")
}))
.chain(new.missing.borrow().iter().map(|base| {
format!("{new_id}: base {base} is in no loaded package; compared over the core type")
}))
.chain(old.borrowed.iter().map(|base| {
format!("{old_id}: base {base} is in no listed dependency; taken from {new_id}'s")
}))
.chain(new.borrowed.iter().map(|base| {
format!("{new_id}: base {base} is in no listed dependency; taken from {old_id}'s")
}))
.collect();
Report {
old: old_id.to_owned(),
new: new_id.to_owned(),
profiles,
notes,
}
}
fn change(
rule: &'static str,
path: &str,
message: &str,
old: Option<String>,
new: Option<String>,
) -> Change {
Change {
rule,
severity: severity_of(rule),
path: path.to_owned(),
message: message.to_owned(),
old,
new,
}
}
fn cardinality(max: Option<u32>) -> String {
max.map_or_else(|| "*".to_owned(), |m| m.to_string())
}
struct Walker<'a> {
old: &'a Pool,
new: &'a Pool,
changes: &'a mut Vec<Change>,
}
impl Walker<'_> {
fn push(
&mut self,
rule: &'static str,
path: &str,
message: String,
old: Option<String>,
new: Option<String>,
) {
self.changes.push(Change {
rule,
severity: severity_of(rule),
path: path.to_owned(),
message,
old,
new,
});
}
fn node(&mut self, path: &str, type_name: &str, old: &Node, new: &Node) {
self.element(path, type_name, old, new, false);
}
fn element(&mut self, path: &str, type_name: &str, old: &Node, new: &Node, variant: bool) {
if !variant {
self.cardinality(path, old, new);
self.types(path, old, new);
self.flags(path, old, new);
self.constraints(path, old, new);
self.slicing(path, old, new);
}
self.binding(path, old, new);
self.pattern(path, old, new);
let names: BTreeSet<&String> = old.children.keys().chain(new.children.keys()).collect();
for name in names {
let child_path = format!("{path}.{name}");
let (o, n) = (old.children.get(name), new.children.get(name));
let child_type = n
.or(o)
.and_then(|c| (c.types.len() == 1).then(|| c.types.iter().next().cloned()))
.flatten()
.unwrap_or_default();
let variant = is_variant(&new.children, name) || is_variant(&old.children, name);
match (o, n) {
(Some(o), Some(n)) => self.element(&child_path, &child_type, o, n, variant),
(Some(o), None) => {
let base = self.base(type_name, name, o);
self.element(&child_path, &child_type, o, &base, variant);
}
(None, Some(n)) => {
let base = self.base(type_name, name, n);
self.element(&child_path, &child_type, &base, n, variant);
}
(None, None) => {}
}
}
if variant {
return;
}
let slices: BTreeSet<&String> = old.slices.keys().chain(new.slices.keys()).collect();
for slice in slices {
let slice_path = format!("{path}:{slice}");
match (old.slices.get(slice), new.slices.get(slice)) {
(Some(o), Some(n)) => self.node(&slice_path, type_name, o, n),
(Some(_), None) => self.push(
"slice-removed",
&slice_path,
format!("Slice {slice} was removed"),
Some(slice.clone()),
None,
),
(None, Some(n)) => {
if n.min > 0 {
self.push(
"min-increased",
&slice_path,
format!("Required slice {slice} was added (min {})", n.min),
Some("0".into()),
Some(n.min.to_string()),
);
} else {
self.push(
"slice-added",
&slice_path,
format!("Optional slice {slice} was added"),
None,
Some(slice.clone()),
);
}
if n.must_support {
self.push(
"must-support-added",
&slice_path,
format!("New slice {slice} is mustSupport"),
None,
Some("true".into()),
);
}
}
(None, None) => {}
}
}
}
fn base(&self, type_name: &str, name: &str, present: &Node) -> Node {
let core = self
.new
.base_node(type_name, name)
.or_else(|| self.old.base_node(type_name, name));
match core {
Some(mut base) => {
base.children.clear();
base
}
None => Node {
types: present.types.clone(),
max: present.max,
targets: present.targets.clone(),
..Node::unconstrained()
},
}
}
fn cardinality(&mut self, path: &str, old: &Node, new: &Node) {
if new.min > old.min {
self.push(
"min-increased",
path,
format!("min {} → {}", old.min, new.min),
Some(old.min.to_string()),
Some(new.min.to_string()),
);
} else if new.min < old.min {
self.push(
"min-decreased",
path,
format!("min {} → {}", old.min, new.min),
Some(old.min.to_string()),
Some(new.min.to_string()),
);
}
let narrower = match (old.max, new.max) {
(None, Some(_)) => Some(true),
(Some(_), None) => Some(false),
(Some(a), Some(b)) if b < a => Some(true),
(Some(a), Some(b)) if b > a => Some(false),
_ => None,
};
if let Some(narrower) = narrower {
self.push(
if narrower {
"max-decreased"
} else {
"max-increased"
},
path,
format!("max {} → {}", cardinality(old.max), cardinality(new.max)),
Some(cardinality(old.max)),
Some(cardinality(new.max)),
);
}
}
fn types(&mut self, path: &str, old: &Node, new: &Node) {
if !old.types.is_empty() && !new.types.is_empty() {
for removed in old.types.difference(&new.types) {
self.push(
"type-removed",
path,
format!("Type {removed} is no longer allowed"),
Some(removed.clone()),
None,
);
}
for added in new.types.difference(&old.types) {
self.push(
"type-added",
path,
format!("Type {added} is allowed"),
None,
Some(added.clone()),
);
}
}
if old.targets.is_empty() && !new.targets.is_empty() {
self.push(
"target-removed",
path,
format!("References are restricted to {}", join(&new.targets)),
None,
Some(join(&new.targets)),
);
} else if !new.targets.is_empty() {
let mut added: Vec<&String> = new.targets.difference(&old.targets).collect();
for removed in old.targets.difference(&new.targets) {
let removed_type = self.old.type_of(removed);
let narrowed = added
.iter()
.position(|a| removed_type.is_some() && self.new.type_of(a) == removed_type);
match narrowed {
Some(index) => {
let to = added.remove(index);
self.push(
"target-narrowed",
path,
format!("Target {removed} narrowed to {to}"),
Some(removed.clone()),
Some(to.clone()),
);
}
None => self.push(
"target-removed",
path,
format!("Target {removed} is no longer allowed"),
Some(removed.clone()),
None,
),
}
}
for added in added {
self.push(
"target-added",
path,
format!("Target {added} is allowed"),
None,
Some(added.clone()),
);
}
}
}
fn flags(&mut self, path: &str, old: &Node, new: &Node) {
match (old.must_support, new.must_support) {
(false, true) => self.push(
"must-support-added",
path,
"Became mustSupport".into(),
Some("false".into()),
Some("true".into()),
),
(true, false) => self.push(
"must-support-removed",
path,
"No longer mustSupport".into(),
Some("true".into()),
Some("false".into()),
),
_ => {}
}
if !path.contains('.') && old.is_modifier != new.is_modifier {
self.push(
"modifier-changed",
path,
format!("isModifier {} → {}", old.is_modifier, new.is_modifier),
Some(old.is_modifier.to_string()),
Some(new.is_modifier.to_string()),
);
}
}
fn binding(&mut self, path: &str, old: &Node, new: &Node) {
let describe = |b: &crate::model::Binding| {
format!(
"{} {}",
b.strength.as_str(),
b.value_set.as_deref().unwrap_or("(no value set)")
)
};
match (&old.binding, &new.binding) {
(None, Some(n)) if n.strength >= Strength::Extensible => {
self.push(
"binding-added",
path,
format!("Bound {}", describe(n)),
None,
Some(describe(n)),
);
}
(Some(o), Some(n)) => {
if n.strength > o.strength {
let rule = if n.strength >= Strength::Extensible {
"binding-stronger"
} else {
"binding-stronger-advisory"
};
self.push(
rule,
path,
format!("{} → {}", o.strength.as_str(), n.strength.as_str()),
Some(describe(o)),
Some(describe(n)),
);
} else if n.strength < o.strength {
self.push(
"binding-weaker",
path,
format!("{} → {}", o.strength.as_str(), n.strength.as_str()),
Some(describe(o)),
Some(describe(n)),
);
}
let plain = |vs: &Option<String>| {
vs.as_deref()
.map(|v| v.split('|').next().unwrap_or(v).to_owned())
};
if plain(&o.value_set) != plain(&n.value_set) && n.strength >= Strength::Extensible
{
self.push(
"value-set-changed",
path,
format!(
"Value set {} → {}",
o.value_set.as_deref().unwrap_or("-"),
n.value_set.as_deref().unwrap_or("-")
),
o.value_set.clone(),
n.value_set.clone(),
);
}
}
_ => {}
}
}
fn constraints(&mut self, path: &str, old: &Node, new: &Node) {
for (key, constraint) in &new.constraints {
match old.constraints.get(key) {
None => {
let rule = if constraint.severity == "error" {
"invariant-added"
} else {
"invariant-warning-added"
};
self.push(
rule,
path,
format!("Invariant {key}: {}", constraint.human),
None,
Some(constraint.expression.clone()),
);
}
Some(previous) if previous.expression != constraint.expression => {
self.push(
"invariant-changed",
path,
format!("Invariant {key} changed"),
Some(previous.expression.clone()),
Some(constraint.expression.clone()),
);
}
Some(_) => {}
}
}
for (key, constraint) in &old.constraints {
if !new.constraints.contains_key(key) {
self.push(
"invariant-removed",
path,
format!("Invariant {key} was removed"),
Some(constraint.expression.clone()),
None,
);
}
}
}
fn pattern(&mut self, path: &str, old: &Node, new: &Node) {
let text = |v: &serde_json::Value| v.to_string();
match (&old.pattern, &new.pattern) {
(None, Some(n)) => self.push(
"fixed-added",
path,
format!("Fixed/pattern {}", text(n)),
None,
Some(text(n)),
),
(Some(o), None) => self.push(
"fixed-removed",
path,
"Fixed/pattern value removed".into(),
Some(text(o)),
None,
),
(Some(o), Some(n)) if o != n => self.push(
"fixed-changed",
path,
"Fixed/pattern value changed".into(),
Some(text(o)),
Some(text(n)),
),
_ => {}
}
}
fn slicing(&mut self, path: &str, old: &Node, new: &Node) {
let rules = |node: &Node| node.slicing.as_ref().map(|s| s.rules.clone());
match (rules(old).as_deref(), rules(new).as_deref()) {
(Some("closed"), Some(r)) if r != "closed" => self.push(
"slicing-opened",
path,
format!("Slicing closed → {r}"),
Some("closed".into()),
Some(r.to_owned()),
),
(o, Some("closed")) if o != Some("closed") => self.push(
"slicing-closed",
path,
format!("Slicing {} → closed", o.unwrap_or("none")),
o.map(str::to_owned),
Some("closed".into()),
),
_ => {}
}
}
}
fn join(set: &BTreeSet<String>) -> String {
set.iter().cloned().collect::<Vec<_>>().join(", ")
}