use std::borrow::Cow;
use std::fmt::{self, Display, Formatter, Write};
use serde::Serialize;
use serde_json::Value;
use crate::format::{format_option_string, make_diff};
use crate::severity::Severity;
#[derive(Debug, Default)]
pub struct Diff {
items: Vec<DiffItem>,
}
impl Diff {
pub(crate) const fn from_items(items: Vec<DiffItem>) -> Self {
Diff { items }
}
#[must_use]
pub fn items(&self) -> &[DiffItem] {
&self.items
}
#[must_use]
pub fn to_json(&self) -> String {
let items: Vec<JsonDiffItem> = self
.items
.iter()
.map(|i| JsonDiffItem {
severity: i.severity().to_string(),
kind: i.kind(),
data: i.data(),
})
.collect();
#[expect(clippy::expect_used)]
serde_json::to_string_pretty(&items).expect("Failed to serialize report as JSON.")
}
}
impl Display for Diff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for item in self.items() {
if f.alternate() {
write!(f, "{item:#}")?;
} else {
write!(f, "{item}")?;
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum DiffItem {
FileAdded {
path: String,
},
FileRemoved {
path: String,
},
FileChanged {
path: String,
diff: Option<String>,
},
LineEndingsChange {
path: String,
},
PermissionChange {
path: String,
old: String,
new: String,
},
NameChange {
old: String,
new: String,
},
VersionChange {
old: String,
new: String,
},
EditionChange {
old: String,
new: String,
},
RustVersionChange {
old: Option<String>,
new: Option<String>,
},
AuthorsChange {
added: Vec<String>,
removed: Vec<String>,
},
DescriptionChange {
old: Option<String>,
new: Option<String>,
},
LicenseChange {
old: Option<String>,
new: Option<String>,
},
LicenseFileChange {
old: Option<String>,
new: Option<String>,
},
ReadmeChange {
old: Option<String>,
new: Option<String>,
},
CategoriesChange {
added: Vec<String>,
removed: Vec<String>,
},
KeywordsChange {
added: Vec<String>,
removed: Vec<String>,
},
RepositoryChange {
old: Option<String>,
new: Option<String>,
},
HomepageChange {
old: Option<String>,
new: Option<String>,
},
DocumentationChange {
old: Option<String>,
new: Option<String>,
},
LinksChange {
old: Option<String>,
new: Option<String>,
},
DefaultRunChange {
old: Option<String>,
new: Option<String>,
},
MetadataChange {
old: serde_json::Value,
new: serde_json::Value,
},
DependencyAdded {
path: String,
value: String,
},
DependencyRemoved {
path: String,
value: String,
},
DependencyUpgraded {
path: String,
old: String,
new: String,
},
DependencyDowngraded {
path: String,
old: String,
new: String,
},
DependencyFeatures {
path: String,
added: Vec<String>,
removed: Vec<String>,
},
DependencyOptionality {
path: String,
old: bool,
new: bool,
},
TargetAdded {
path: String,
target: String,
},
TargetRemoved {
path: String,
target: String,
},
TargetChanged {
path: String,
old: String,
new: String,
},
FeatureAdded {
name: String,
},
FeatureRemoved {
name: String,
},
FeatureChanged {
name: String,
added: Vec<String>,
removed: Vec<String>,
},
}
#[derive(Serialize)]
struct JsonDiffItem {
severity: String,
kind: &'static str,
data: Value,
}
impl DiffItem {
#[must_use]
pub const fn severity(&self) -> Severity {
#[expect(clippy::match_same_arms)]
match self {
Self::FileAdded { .. } => Severity::Info,
Self::FileRemoved { .. } => Severity::Info,
Self::FileChanged { .. } => Severity::Info,
Self::LineEndingsChange { .. } => Severity::Warning,
Self::PermissionChange { .. } => Severity::Warning,
Self::NameChange { .. } => Severity::Error,
Self::VersionChange { .. } => Severity::Info,
Self::EditionChange { .. } => Severity::Warning,
Self::RustVersionChange { .. } => Severity::Warning,
Self::AuthorsChange { .. } => Severity::Warning,
Self::DescriptionChange { .. } => Severity::Info,
Self::LicenseChange { .. } => Severity::Warning,
Self::LicenseFileChange { .. } => Severity::Warning,
Self::ReadmeChange { .. } => Severity::Info,
Self::CategoriesChange { .. } => Severity::Info,
Self::KeywordsChange { .. } => Severity::Info,
Self::RepositoryChange { .. } => Severity::Warning,
Self::HomepageChange { .. } => Severity::Info,
Self::DocumentationChange { .. } => Severity::Info,
Self::LinksChange { .. } => Severity::Warning,
Self::DefaultRunChange { .. } => Severity::Info,
Self::MetadataChange { .. } => Severity::Info,
Self::DependencyAdded { .. } => Severity::Info,
Self::DependencyRemoved { .. } => Severity::Info,
Self::DependencyUpgraded { .. } => Severity::Info,
Self::DependencyDowngraded { .. } => Severity::Warning,
Self::DependencyFeatures { .. } => Severity::Info,
Self::DependencyOptionality { .. } => Severity::Info,
Self::TargetAdded { .. } => Severity::Info,
Self::TargetRemoved { .. } => Severity::Info,
Self::TargetChanged { .. } => Severity::Info,
Self::FeatureAdded { .. } => Severity::Info,
Self::FeatureRemoved { .. } => Severity::Warning,
Self::FeatureChanged { .. } => Severity::Info,
}
}
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::FileAdded { .. } => "FileAdded",
Self::FileRemoved { .. } => "FileRemoved",
Self::FileChanged { .. } => "ContentChange",
Self::LineEndingsChange { .. } => "LineEndingsChange",
Self::PermissionChange { .. } => "PermissionChange",
Self::NameChange { .. } => "NameChange",
Self::VersionChange { .. } => "VersionChange",
Self::EditionChange { .. } => "EditionChange",
Self::RustVersionChange { .. } => "RustVersionChange",
Self::AuthorsChange { .. } => "AuthorsChange",
Self::DescriptionChange { .. } => "DescriptionChange",
Self::LicenseChange { .. } => "LicenseChange",
Self::LicenseFileChange { .. } => "LicenseFileChange",
Self::ReadmeChange { .. } => "ReadmeChange",
Self::CategoriesChange { .. } => "CategoriesChange",
Self::KeywordsChange { .. } => "KeywordsChange",
Self::RepositoryChange { .. } => "RepositoryChange",
Self::HomepageChange { .. } => "HomepageChange",
Self::DocumentationChange { .. } => "DocumentationChange",
Self::LinksChange { .. } => "LinksChange",
Self::DefaultRunChange { .. } => "DefaultRunChange",
Self::MetadataChange { .. } => "MetadataChange",
Self::DependencyAdded { .. } => "DependencyAdded",
Self::DependencyRemoved { .. } => "DependencyRemoved",
Self::DependencyUpgraded { .. } => "DependencyUpgraded",
Self::DependencyDowngraded { .. } => "DependencyDowngraded",
Self::DependencyFeatures { .. } => "DependencyFeatures",
Self::DependencyOptionality { .. } => "DependencyOptionality",
Self::TargetAdded { .. } => "TargetAdded",
Self::TargetRemoved { .. } => "TargetRemoved",
Self::TargetChanged { .. } => "TargetChanged",
Self::FeatureAdded { .. } => "FeatureAdded",
Self::FeatureRemoved { .. } => "FeatureRemoved",
Self::FeatureChanged { .. } => "FeatureChanged",
}
}
#[expect(clippy::too_many_lines)]
#[must_use]
pub fn data(&self) -> Value {
let mut data = serde_json::Map::new();
#[expect(clippy::match_same_arms)]
match self {
Self::FileAdded { path } => {
data.insert(String::from("path"), Value::from(path.clone()));
},
Self::FileRemoved { path } => {
data.insert(String::from("path"), Value::from(path.clone()));
},
Self::FileChanged { path, diff } => {
data.insert(String::from("path"), Value::from(path.clone()));
data.insert(String::from("diff"), Value::from(diff.clone()));
},
Self::LineEndingsChange { path } => {
data.insert(String::from("path"), Value::from(Some(path.clone())));
},
Self::PermissionChange { path, old, new } => {
data.insert(String::from("path"), Value::from(path.clone()));
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::NameChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::VersionChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::EditionChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::RustVersionChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::AuthorsChange { added, removed } => {
data.insert(String::from("added"), Value::from(added.clone()));
data.insert(String::from("removed"), Value::from(removed.clone()));
},
Self::DescriptionChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::LicenseChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::LicenseFileChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::ReadmeChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::CategoriesChange { added, removed } => {
data.insert(String::from("added"), Value::from(added.clone()));
data.insert(String::from("removed"), Value::from(removed.clone()));
},
Self::KeywordsChange { added, removed } => {
data.insert(String::from("added"), Value::from(added.clone()));
data.insert(String::from("removed"), Value::from(removed.clone()));
},
Self::RepositoryChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::HomepageChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::DocumentationChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::LinksChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::DefaultRunChange { old, new } => {
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::MetadataChange { old, new } => {
data.insert(String::from("old"), old.clone());
data.insert(String::from("new"), new.clone());
},
Self::DependencyAdded { path, value } => {
data.insert(String::from("path"), Value::from(path.clone()));
data.insert(String::from("value"), Value::from(value.clone()));
},
Self::DependencyRemoved { path, value } => {
data.insert(String::from("path"), Value::from(path.clone()));
data.insert(String::from("value"), Value::from(value.clone()));
},
Self::DependencyUpgraded { path, old, new } => {
data.insert(String::from("path"), Value::from(path.clone()));
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::DependencyDowngraded { path, old, new } => {
data.insert(String::from("path"), Value::from(path.clone()));
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::DependencyFeatures { path, removed, added } => {
data.insert(String::from("path"), Value::from(path.clone()));
data.insert(String::from("removed"), Value::from(removed.clone()));
data.insert(String::from("added"), Value::from(added.clone()));
},
Self::DependencyOptionality { path, old, new } => {
data.insert(String::from("path"), Value::from(path.clone()));
data.insert(String::from("old"), Value::from(old.to_string()));
data.insert(String::from("new"), Value::from(new.to_string()));
},
Self::TargetAdded { path, target } => {
data.insert(String::from("path"), Value::from(path.clone()));
data.insert(String::from("target"), Value::from(target.clone()));
},
Self::TargetRemoved { path, target } => {
data.insert(String::from("path"), Value::from(path.clone()));
data.insert(String::from("target"), Value::from(target.clone()));
},
Self::TargetChanged { path, old, new } => {
data.insert(String::from("path"), Value::from(path.clone()));
data.insert(String::from("old"), Value::from(old.clone()));
data.insert(String::from("new"), Value::from(new.clone()));
},
Self::FeatureAdded { name } => {
data.insert(String::from("name"), Value::from(name.clone()));
},
Self::FeatureRemoved { name } => {
data.insert(String::from("name"), Value::from(name.clone()));
},
Self::FeatureChanged { name, removed, added } => {
data.insert(String::from("name"), Value::from(name.clone()));
data.insert(String::from("added"), Value::from(added.clone()));
data.insert(String::from("removed"), Value::from(removed.clone()));
},
}
Value::Object(data)
}
#[must_use]
pub fn message(&self) -> Cow<'static, str> {
match self {
Self::FileAdded { path } => format!("file added at path '{path}'").into(),
Self::FileRemoved { path } => format!("file removed at path '{path}'").into(),
Self::FileChanged { path, .. } => format!("file at path '{path}' changed").into(),
Self::LineEndingsChange { path } => {
format!("file at path '{path}' changed line endings (CRLF / LF)").into()
},
Self::PermissionChange { path, old, new } => {
format!("file at path '{path}' changed mode from {old} to {new}").into()
},
Self::NameChange { old, new } => format!("crate name changed from '{old}' to '{new}'").into(),
Self::VersionChange { old, new } => format!("crate version changed from '{old}' to '{new}'").into(),
Self::EditionChange { old, new } => format!("crate edition changed from '{old}' to '{new}'").into(),
Self::RustVersionChange { old, new } => {
let old_msrv = format_option_string(old.as_ref());
let new_msrv = format_option_string(new.as_ref());
format!("crate MSRV changed from '{old_msrv}' to '{new_msrv}'").into()
},
Self::AuthorsChange { .. } => Into::into("crate authors changed"),
Self::DescriptionChange { .. } => Into::into("crate description changed"),
Self::LicenseChange { old, new } => {
let old_license = format_option_string(old.as_ref());
let new_license = format_option_string(new.as_ref());
format!("crate license changed from '{old_license}' to '{new_license}'").into()
},
Self::LicenseFileChange { old, new } => {
let old_path = format_option_string(old.as_ref());
let new_path = format_option_string(new.as_ref());
format!("crate license file changed from '{old_path}' to '{new_path}'").into()
},
Self::ReadmeChange { old, new } => {
let old_path = format_option_string(old.as_ref());
let new_path = format_option_string(new.as_ref());
format!("crate readme file changed from '{old_path}' to '{new_path}'").into()
},
Self::CategoriesChange { .. } => Into::into("crate categories changed"),
Self::KeywordsChange { .. } => Into::into("crate keywords changed"),
Self::RepositoryChange { old, new } => {
let old_url = format_option_string(old.as_ref());
let new_url = format_option_string(new.as_ref());
format!("crate repository URL file changed from '{old_url}' to '{new_url}'").into()
},
Self::HomepageChange { old, new } => {
let old_url = format_option_string(old.as_ref());
let new_url = format_option_string(new.as_ref());
format!("crate homepage URL file changed from '{old_url}' to '{new_url}'").into()
},
Self::DocumentationChange { old, new } => {
let old_url = format_option_string(old.as_ref());
let new_url = format_option_string(new.as_ref());
format!("crate documentation URL file changed from '{old_url}' to '{new_url}'").into()
},
Self::LinksChange { old, new } => {
let old_link = format_option_string(old.as_ref());
let new_link = format_option_string(new.as_ref());
format!("linked library changed from '{old_link}' to '{new_link}'").into()
},
Self::DefaultRunChange { old, new } => {
let old_target = format_option_string(old.as_ref());
let new_target = format_option_string(new.as_ref());
format!("default 'run' target changed from '{old_target}' to '{new_target}'").into()
},
Self::MetadataChange { .. } => Into::into("additional free-form crate metadata changed"),
Self::DependencyAdded { path, .. } => format!("dependency '{path}' added").into(),
Self::DependencyRemoved { path, .. } => format!("dependency '{path}' removed").into(),
Self::DependencyUpgraded { path, old, new } => {
format!("dependency '{path}' upgraded from {old} to {new}").into()
},
Self::DependencyDowngraded { path, old, new } => {
format!("dependency '{path}' downgraded from {old} to {new}").into()
},
Self::DependencyFeatures { path, .. } => format!("dependency '{path}' features changed").into(),
Self::DependencyOptionality { path, old, new } => {
if !*old && *new {
format!("dependency '{path}' is now optional").into()
} else {
format!("dependency '{path}' is no longer optional").into()
}
},
Self::TargetAdded { path, .. } => format!("compilation target '{path}' added").into(),
Self::TargetRemoved { path, .. } => format!("compilation target '{path}' removed").into(),
Self::TargetChanged { path, .. } => format!("compilation target '{path}' changed").into(),
Self::FeatureAdded { name } => format!("crate feature added: '{name}'").into(),
Self::FeatureRemoved { name } => format!("crate feature removed: '{name}'").into(),
Self::FeatureChanged { name, .. } => format!("crate feature '{name}' dependencies changed").into(),
}
}
#[must_use]
pub fn extra(&self) -> Option<String> {
match self {
Self::FileChanged { diff, .. } => diff.as_ref().map(ToOwned::to_owned),
Self::AuthorsChange { added, removed }
| Self::CategoriesChange { added, removed }
| Self::KeywordsChange { added, removed }
| Self::DependencyFeatures { added, removed, .. }
| Self::FeatureChanged { added, removed, .. } => {
let mut out = String::new();
if !added.is_empty() {
let _ = writeln!(out, "added: {}", added.join(", "));
}
if !removed.is_empty() {
let _ = writeln!(out, "removed: {}", removed.join(", "));
}
Some(out)
},
Self::DescriptionChange { old, new } => {
let old_desc = old.as_ref().map_or("", String::as_str);
let new_desc = new.as_ref().map_or("", String::as_str);
let header = Some(("old/package.description", "new/package.description"));
Some(make_diff(old_desc, new_desc, header))
},
Self::MetadataChange { old, new } => {
let mut out = String::new();
let _ = writeln!(out, "old: {old}");
let _ = writeln!(out, "new: {new}");
Some(out)
},
Self::DependencyAdded { value, .. } | Self::DependencyRemoved { value, .. } => Some(value.clone()),
Self::TargetAdded { target, .. } | Self::TargetRemoved { target, .. } => Some(target.clone()),
Self::TargetChanged { old, new, .. } => {
let mut out = String::new();
let _ = writeln!(out, "old: {old}");
let _ = writeln!(out, "new: {new}");
Some(out)
},
_ => None,
}
}
}
impl Display for DiffItem {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
if f.alternate() {
writeln!(f, "{}: {}", self.severity(), self.message())?;
if let Some(extra) = self.extra() {
for line in extra.lines() {
writeln!(f, " {line}")?;
}
writeln!(f)?;
}
Ok(())
} else {
writeln!(f, "{}: {}", self.severity(), self.message())
}
}
}