use std::path::Path;
use ridl_ir::v2::{Package, System};
mod classify;
pub mod system;
mod walk;
pub use classify::{category_from_word, classify, explain};
pub use system::{SystemChange, SystemHeading, diff_systems};
#[cfg(test)]
mod tests;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Verdict {
Identical,
Compatible,
Breaking,
}
macro_rules! declare_categories {
(
$(#[$enum_meta:meta])*
$vis:vis enum $name:ident {
$(
$(#[$variant_meta:meta])*
$variant:ident,
)+
}
) => {
$(#[$enum_meta])*
$vis enum $name {
$(
$(#[$variant_meta])*
$variant,
)+
}
pub const CATEGORIES: [$name; [$(stringify!($variant)),+].len()] =
[$($name::$variant),+];
};
}
declare_categories! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Category {
DeclAdded,
DeclRemoved,
InterfaceRenamed,
InterfaceRetired,
MemberReordered,
InteractionAppended,
InteractionInserted,
InteractionReordered,
InteractionRemoved,
InteractionRetired,
KindChanged,
PayloadChanged,
ReturnChanged,
ParamsChanged,
TimingChanged,
RpcBoundChanged,
ContractChanged,
WidthChanged,
ConstraintChanged,
InitChanged,
ReservedNameRedeclared,
ServiceChanged,
ServiceInterfaceAdded,
ServiceInterfaceRemoved,
DocOnly,
VisibilityChanged,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Change {
pub path: String,
pub category: Category,
pub verdict: Verdict,
pub before: Option<String>,
pub after: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiffReport {
pub changes: Vec<Change>,
pub verdict: Verdict,
pub system: Vec<SystemChange>,
}
#[derive(Debug)]
pub enum LoadError {
Io(std::io::Error),
Parse(String),
}
impl std::fmt::Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LoadError::Io(err) => write!(f, "cannot read the IR snapshot: {err}"),
LoadError::Parse(err) => write!(f, "the IR snapshot is not valid IR v2 JSON: {err}"),
}
}
}
impl std::error::Error for LoadError {}
pub(crate) fn emit(
changes: &mut Vec<Change>,
path: String,
category: Category,
before: Option<String>,
after: Option<String>,
) {
changes.push(Change {
path,
category,
verdict: Verdict::Breaking,
before,
after,
});
}
pub(crate) fn frozen(interface: &ridl_ir::v2::Interface) -> bool {
interface.number != 0 && !interface.provisional
}
fn classify_all(changes: &mut [Change], old: &Package, new: &Package) {
for change in changes {
change.verdict = classify(change, old, new);
}
}
pub(crate) fn report(changes: Vec<Change>) -> DiffReport {
let verdict = changes
.iter()
.map(|change| change.verdict)
.max()
.unwrap_or(Verdict::Identical);
DiffReport {
changes,
verdict,
system: Vec::new(),
}
}
pub fn diff_packages(old: &Package, new: &Package) -> DiffReport {
let mut changes = Vec::new();
walk::walk_packages(old, new, &mut changes);
classify_all(&mut changes, old, new);
report(changes)
}
pub fn diff_sets(old: &[Package], new: &[Package]) -> DiffReport {
use std::collections::BTreeMap;
let old_by: BTreeMap<&str, &Package> = old.iter().map(|pkg| (pkg.name.as_str(), pkg)).collect();
let new_by: BTreeMap<&str, &Package> = new.iter().map(|pkg| (pkg.name.as_str(), pkg)).collect();
let mut changes = Vec::new();
for (name, old_pkg) in &old_by {
match new_by.get(name) {
Some(new_pkg) => {
let mut pair = Vec::new();
walk::walk_packages(old_pkg, new_pkg, &mut pair);
classify_all(&mut pair, old_pkg, new_pkg);
changes.append(&mut pair);
}
None => {
let mut pair = Vec::new();
emit(
&mut pair,
(*name).to_string(),
Category::DeclRemoved,
Some(format!("package {name}")),
None,
);
classify_all(&mut pair, old_pkg, old_pkg);
changes.append(&mut pair);
}
}
}
for (name, new_pkg) in &new_by {
if !old_by.contains_key(name) {
let mut pair = Vec::new();
emit(
&mut pair,
(*name).to_string(),
Category::DeclAdded,
None,
Some(format!("package {name}")),
);
classify_all(&mut pair, new_pkg, new_pkg);
changes.append(&mut pair);
}
}
report(changes)
}
pub fn diff_workspaces(
old: &[Package],
old_system: Option<&System>,
new: &[Package],
new_system: Option<&System>,
) -> DiffReport {
let mut report = diff_sets(old, new);
if let (Some(old_system), Some(new_system)) = (old_system, new_system) {
report.system = diff_systems(old_system, new_system);
}
report
}
pub fn load_ir_json(path: &Path) -> Result<Package, LoadError> {
let text = std::fs::read_to_string(path).map_err(LoadError::Io)?;
ridl_ir::v2::from_json(&text).map_err(|err| LoadError::Parse(err.to_string()))
}
pub(crate) fn verdict_word(verdict: Verdict) -> &'static str {
match verdict {
Verdict::Identical => "identical",
Verdict::Compatible => "compatible",
Verdict::Breaking => "breaking",
}
}
#[deny(
clippy::wildcard_enum_match_arm,
clippy::match_wildcard_for_single_variants
)]
pub fn category_word(category: Category) -> &'static str {
match category {
Category::DeclAdded => "decl_added",
Category::DeclRemoved => "decl_removed",
Category::InterfaceRenamed => "interface_renamed",
Category::InterfaceRetired => "interface_retired",
Category::MemberReordered => "member_reordered",
Category::InteractionAppended => "interaction_appended",
Category::InteractionInserted => "interaction_inserted",
Category::InteractionReordered => "interaction_reordered",
Category::InteractionRemoved => "interaction_removed",
Category::InteractionRetired => "interaction_retired",
Category::KindChanged => "kind_changed",
Category::PayloadChanged => "payload_changed",
Category::ReturnChanged => "return_changed",
Category::ParamsChanged => "params_changed",
Category::TimingChanged => "timing_changed",
Category::RpcBoundChanged => "rpc_bound_changed",
Category::ContractChanged => "contract_changed",
Category::WidthChanged => "width_changed",
Category::ConstraintChanged => "constraint_changed",
Category::InitChanged => "init_changed",
Category::ReservedNameRedeclared => "reserved_name_redeclared",
Category::ServiceChanged => "service_changed",
Category::ServiceInterfaceAdded => "service_interface_added",
Category::ServiceInterfaceRemoved => "service_interface_removed",
Category::DocOnly => "doc_only",
Category::VisibilityChanged => "visibility_changed",
}
}
#[deny(
clippy::wildcard_enum_match_arm,
clippy::match_wildcard_for_single_variants
)]
pub fn heading(category: Category) -> Option<&'static str> {
match category {
Category::InterfaceRenamed | Category::ServiceInterfaceRemoved => {
Some("compatible on the wire, visible in source")
}
Category::DeclAdded
| Category::DeclRemoved
| Category::InterfaceRetired
| Category::MemberReordered
| Category::InteractionAppended
| Category::InteractionInserted
| Category::InteractionReordered
| Category::InteractionRemoved
| Category::InteractionRetired
| Category::KindChanged
| Category::PayloadChanged
| Category::ReturnChanged
| Category::ParamsChanged
| Category::TimingChanged
| Category::RpcBoundChanged
| Category::ContractChanged
| Category::WidthChanged
| Category::ConstraintChanged
| Category::InitChanged
| Category::ReservedNameRedeclared
| Category::ServiceChanged
| Category::ServiceInterfaceAdded
| Category::DocOnly
| Category::VisibilityChanged => None,
}
}
impl serde::Serialize for Verdict {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(verdict_word(*self))
}
}
impl serde::Serialize for Category {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(category_word(*self))
}
}
impl serde::Serialize for Change {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct as _;
let mut state = serializer.serialize_struct("Change", 5)?;
state.serialize_field("path", &self.path)?;
state.serialize_field("category", &self.category)?;
state.serialize_field("verdict", &self.verdict)?;
state.serialize_field("before", &self.before)?;
state.serialize_field("after", &self.after)?;
state.end()
}
}
pub fn render_text(report: &DiffReport) -> String {
let mut out = String::new();
out.push_str(verdict_word(report.verdict));
out.push('\n');
for change in &report.changes {
if heading(change.category).is_none() {
push_change_line(&mut out, change);
}
}
let mut printed: Vec<&'static str> = Vec::new();
for category in CATEGORIES {
let Some(title) = heading(category) else {
continue;
};
if printed.contains(&title) {
continue;
}
printed.push(title);
let mut under = report
.changes
.iter()
.filter(|change| heading(change.category) == Some(title))
.peekable();
if under.peek().is_none() {
continue;
}
out.push_str(title);
out.push_str(":\n");
for change in under {
push_change_line(&mut out, change);
}
}
for heading in [
SystemHeading::PlacementChanged,
SystemHeading::CompositionChanged,
] {
let mut listed = report
.system
.iter()
.filter(|change| change.heading == heading)
.peekable();
if listed.peek().is_none() {
continue;
}
out.push_str(system::heading_text(heading));
out.push('\n');
for change in listed {
out.push_str(" ");
out.push_str(&change.path);
push_values(&mut out, change.before.as_ref(), change.after.as_ref());
out.push('\n');
}
}
out
}
fn push_change_line(out: &mut String, change: &Change) {
out.push_str(" [");
out.push_str(verdict_word(change.verdict));
out.push_str("] ");
out.push_str(category_word(change.category));
out.push(' ');
out.push_str(&change.path);
push_values(out, change.before.as_ref(), change.after.as_ref());
out.push('\n');
}
fn push_values(out: &mut String, before: Option<&String>, after: Option<&String>) {
match (before, after) {
(Some(before), Some(after)) => {
out.push_str(": ");
out.push_str(before);
out.push_str(" -> ");
out.push_str(after);
}
(Some(before), None) => {
out.push_str(": ");
out.push_str(before);
out.push_str(" -> (removed)");
}
(None, Some(after)) => {
out.push_str(": (absent) -> ");
out.push_str(after);
}
(None, None) => {}
}
}
pub fn render_json(report: &DiffReport) -> String {
#[derive(serde::Serialize)]
struct JsonSystemChange<'a> {
path: &'a str,
before: Option<&'a str>,
after: Option<&'a str>,
}
#[derive(serde::Serialize)]
struct JsonReport<'a> {
verdict: Verdict,
changes: &'a [Change],
#[serde(skip_serializing_if = "Vec::is_empty")]
placement_changed: Vec<JsonSystemChange<'a>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
composition_changed: Vec<JsonSystemChange<'a>>,
}
let under = |heading: SystemHeading| -> Vec<JsonSystemChange<'_>> {
report
.system
.iter()
.filter(|change| change.heading == heading)
.map(|change| JsonSystemChange {
path: &change.path,
before: change.before.as_deref(),
after: change.after.as_deref(),
})
.collect()
};
serde_json::to_string_pretty(&JsonReport {
verdict: report.verdict,
changes: &report.changes,
placement_changed: under(SystemHeading::PlacementChanged),
composition_changed: under(SystemHeading::CompositionChanged),
})
.expect("a diff report holds only string-representable values, so serialization cannot fail")
}