use std::{
cmp::Ordering,
fmt::{
self,
Write as _,
},
num::NonZeroUsize,
};
use dix_diff::VersionPiece;
use itertools::{
EitherOrBoth,
Itertools,
};
use size::Size;
use unicode_width::UnicodeWidthStr as _;
use yansi::{
Paint as _,
Painted,
};
use crate::{
DerivationSelectionStatus,
DiffReport,
DiffStatus,
PackageDiff,
PackageSizeDelta,
PathStats,
Version,
VersionAmount,
VersionDiff,
};
pub fn write_diff_report(
writer: &mut impl fmt::Write,
report: &DiffReport,
) -> Result<usize, fmt::Error> {
writeln!(writer)?;
let wrote = render_package_diffs(writer, report.diffs())?;
if wrote > 0 {
writeln!(writer)?;
}
write_path_stats(writer, report.path_stats())?;
write_size_diff(writer, report.size_old(), report.size_new())?;
Ok(wrote)
}
fn render_package_diffs(
writer: &mut impl fmt::Write,
diffs: &[PackageDiff],
) -> Result<usize, fmt::Error> {
let mut diffs = diffs.iter().collect::<Vec<_>>();
diffs.sort_by(|a, b| {
status_group(a.status)
.cmp(&status_group(b.status))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.status.cmp(&b.status))
});
render_diffs(writer, &diffs)
}
fn render_diffs(
writer: &mut impl fmt::Write,
diffs: &[&PackageDiff],
) -> Result<usize, fmt::Error> {
let name_width = diffs
.iter()
.map(|diff| diff.name.width())
.max()
.unwrap_or(0)
+ 1;
let mut last_status_group = None::<StatusGroup>;
for diff in diffs {
let group = status_group(diff.status);
if last_status_group.is_none_or(|last_group| last_group != group) {
if last_status_group.is_some() {
writeln!(writer)?;
}
let header = match group {
StatusGroup::Changed => "CHANGED",
StatusGroup::Added => "ADDED",
StatusGroup::Removed => "REMOVED",
}
.bold();
writeln!(writer, "{header}")?;
last_status_group = Some(group);
}
let status_char = status_char(diff.status);
let selection_char = selection_char(diff.selection);
let name_painted = diff.name.paint(selection_char.style);
write!(
writer,
"[{status_char}{selection_char}] {name_painted:<name_width$}"
)?;
let (old_str, new_str) =
fmt_version_diffs(&diff.versions, diff.has_omitted_versions)?;
let arrow = if !old_str.is_empty() && !new_str.is_empty() {
" -> "
} else {
""
};
let size_delta = fmt_package_size_delta(diff.size);
if old_str.is_empty() && new_str.is_empty() {
writeln!(writer, "{size_delta}")?;
} else if size_delta.is_empty() {
writeln!(writer, "{old_str}{arrow}{new_str}")?;
} else {
writeln!(writer, "{old_str}{arrow}{new_str}, {size_delta}")?;
}
}
Ok(diffs.len())
}
fn fmt_package_size_delta(size: PackageSizeDelta) -> String {
if !size.is_significant() {
return String::new();
}
let delta = size.delta();
if delta.bytes() > 0 {
format!("+{delta}").bright_cyan().to_string()
} else {
delta.magenta().to_string()
}
}
fn write_size_diff(
writer: &mut impl fmt::Write,
size_old: Size,
size_new: Size,
) -> fmt::Result {
let size_diff = size_new - size_old;
writeln!(
writer,
"{header}: {size_old} -> {size_new}",
header = "SIZE".bold(),
size_old = size_old.red(),
size_new = size_new.green(),
)?;
let (sign, styled_diff) = match size_diff.bytes().cmp(&0) {
Ordering::Less => ("", size_diff.red()),
Ordering::Equal => ("", size_diff.resetting()),
Ordering::Greater => ("+", size_diff.green()),
};
writeln!(writer, "{}: {sign}{styled_diff}", "DIFF".bold())
}
fn write_path_stats(
writer: &mut impl fmt::Write,
stats: PathStats,
) -> fmt::Result {
let added = format!("+{}", stats.added_count());
let removed = format!("-{}", stats.removed_count());
writeln!(
writer,
"{header}: {old} -> {new} ({added}, {removed})",
header = "PATHS".bold(),
old = stats.old_count().red(),
new = stats.new_count().green(),
added = Painted::new(added.as_str()).green(),
removed = Painted::new(removed.as_str()).red(),
)
}
fn status_char(status: DiffStatus) -> Painted<&'static char> {
match status {
DiffStatus::Changed | DiffStatus::Mixed => 'C'.yellow().bold(),
DiffStatus::Upgraded => 'U'.bright_cyan().bold(),
DiffStatus::Downgraded => 'D'.magenta().bold(),
DiffStatus::Added => 'A'.green().bold(),
DiffStatus::Removed => 'R'.red().bold(),
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum StatusGroup {
Changed,
Added,
Removed,
}
const fn status_group(status: DiffStatus) -> StatusGroup {
match status {
DiffStatus::Changed
| DiffStatus::Mixed
| DiffStatus::Upgraded
| DiffStatus::Downgraded => StatusGroup::Changed,
DiffStatus::Added => StatusGroup::Added,
DiffStatus::Removed => StatusGroup::Removed,
}
}
fn selection_char(status: DerivationSelectionStatus) -> Painted<&'static char> {
match status {
DerivationSelectionStatus::Selected => '*'.bold(),
DerivationSelectionStatus::NewlySelected => '+'.bold(),
DerivationSelectionStatus::Unselected => Painted::new(&'.'),
DerivationSelectionStatus::NewlyUnselected => Painted::new(&'-'),
}
}
fn fmt_version_diffs(
version_diffs: &[VersionDiff],
has_omitted_versions: bool,
) -> Result<(String, String), fmt::Error> {
let mut old_acc = String::new();
let mut new_acc = String::new();
let mut old_wrote = false;
let mut new_wrote = false;
let append_sep = |acc: &mut String, wrote: &mut bool| {
if *wrote {
write!(acc, ", ")
} else {
*wrote = true;
Ok(())
}
};
#[expect(clippy::redundant_closure_for_method_calls)]
for diff in version_diffs {
match diff {
VersionDiff::Removed(old) => {
append_sep(&mut old_acc, &mut old_wrote)?;
write_version_amount(&mut old_acc, old, |value| value.red())?;
},
VersionDiff::Added(new) => {
append_sep(&mut new_acc, &mut new_wrote)?;
write_version_amount(&mut new_acc, new, |value| value.green())?;
},
VersionDiff::Changed { old, new } => {
if old == new {
continue;
}
append_sep(&mut old_acc, &mut old_wrote)?;
append_sep(&mut new_acc, &mut new_wrote)?;
fmt_single_version_diff(
&mut old_acc,
&mut new_acc,
&old.version,
&new.version,
)?;
write_changed_amounts(
&mut old_acc,
&mut new_acc,
old.amount,
new.amount,
)?;
},
VersionDiff::AmountChanged {
version,
old_amount,
new_amount,
} => {
append_sep(&mut old_acc, &mut old_wrote)?;
append_sep(&mut new_acc, &mut new_wrote)?;
write_version(&mut old_acc, version, |value| value.yellow())?;
write_version(&mut new_acc, version, |value| value.yellow())?;
write_amount_suffix(&mut old_acc, *old_amount, |value| value.red())?;
write_amount_suffix(&mut new_acc, *new_amount, |value| value.green())?;
},
}
}
if has_omitted_versions {
let unchanged_str = "<unchanged>".blue().italic().to_string();
append_sep(&mut old_acc, &mut old_wrote)?;
append_sep(&mut new_acc, &mut new_wrote)?;
write!(old_acc, "{unchanged_str}")?;
write!(new_acc, "{unchanged_str}")?;
}
Ok((old_acc, new_acc))
}
fn write_version_amount(
buf: &mut String,
version: &VersionAmount,
style: impl Copy + Fn(Painted<&str>) -> Painted<&str>,
) -> fmt::Result {
write_version(buf, &version.version, style)?;
write_amount_suffix(buf, version.amount, style)
}
fn write_version(
buf: &mut String,
version: &Version,
style: impl Copy + Fn(Painted<&str>) -> Painted<&str>,
) -> fmt::Result {
for piece in version {
write_version_piece(buf, &piece, style)?;
}
Ok(())
}
fn write_amount_suffix(
buf: &mut String,
amount: NonZeroUsize,
style: impl Fn(Painted<&str>) -> Painted<&str>,
) -> fmt::Result {
if amount.get() > 1 {
let amount = amount.get().to_string();
write!(buf, " ×{}", style(Painted::new(amount.as_str())))?;
}
Ok(())
}
#[expect(clippy::redundant_closure_for_method_calls)]
fn write_changed_amounts(
old_acc: &mut String,
new_acc: &mut String,
old_amount: NonZeroUsize,
new_amount: NonZeroUsize,
) -> fmt::Result {
if old_amount == new_amount {
write_amount_suffix(old_acc, old_amount, |value| value.yellow())?;
write_amount_suffix(new_acc, new_amount, |value| value.yellow())
} else {
write_amount_suffix(old_acc, old_amount, |value| value.red())?;
write_amount_suffix(new_acc, new_amount, |value| value.green())
}
}
fn write_version_piece(
buf: &mut String,
piece: &VersionPiece,
style: impl Fn(Painted<&str>) -> Painted<&str>,
) -> fmt::Result {
match *piece {
VersionPiece::Component(component) => {
write!(buf, "{}", style(Painted::new(*component)))
},
VersionPiece::Separator(separator) => write!(buf, "{separator}"),
}
}
fn fmt_single_version_diff(
old_acc: &mut String,
new_acc: &mut String,
old_ver: &Version,
new_ver: &Version,
) -> fmt::Result {
let old_parts: Vec<_> = old_ver.into_iter().collect();
let new_parts: Vec<_> = new_ver.into_iter().collect();
if (old_parts.is_empty() && new_parts.is_empty()) || (old_ver == new_ver) {
return Ok(());
}
let prefix_len = old_parts
.iter()
.zip(&new_parts)
.take_while(|&(old_part, new_part)| old_part == new_part)
.count();
let old_remainder = &old_parts[prefix_len..];
let new_remainder = &new_parts[prefix_len..];
let suffix_len = if !old_remainder.is_empty() && !new_remainder.is_empty() {
old_remainder
.iter()
.rev()
.zip(new_remainder.iter().rev())
.take_while(|&(old_part, new_part)| old_part == new_part)
.count()
} else {
0
};
let prefix = &old_parts[..prefix_len];
let old_diff_end = old_parts.len() - suffix_len;
let new_diff_end = new_parts.len() - suffix_len;
let old_diff = &old_parts[prefix_len..old_diff_end];
let new_diff = &new_parts[prefix_len..new_diff_end];
let suffix = if suffix_len > 0 {
&old_parts[old_diff_end..]
} else {
&[]
};
#[expect(clippy::redundant_closure_for_method_calls)]
for piece in prefix {
write_version_piece(old_acc, piece, |value| value.yellow())?;
write_version_piece(new_acc, piece, |value| value.yellow())?;
}
for pair in Itertools::zip_longest(old_diff.iter(), new_diff.iter()) {
#[expect(clippy::redundant_closure_for_method_calls)]
match pair {
EitherOrBoth::Left(old) => {
write_version_piece(old_acc, old, |value| value.red())?;
},
EitherOrBoth::Right(new) => {
write_version_piece(new_acc, new, |value| value.green())?;
},
EitherOrBoth::Both(old, new) => {
fmt_version_piece_pair(old_acc, new_acc, old, new)?;
},
}
}
#[expect(clippy::redundant_closure_for_method_calls)]
for piece in suffix {
write_version_piece(old_acc, piece, |value| value.yellow())?;
write_version_piece(new_acc, piece, |value| value.yellow())?;
}
Ok(())
}
fn fmt_version_piece_pair(
old_acc: &mut String,
new_acc: &mut String,
old_piece: &VersionPiece,
new_piece: &VersionPiece,
) -> fmt::Result {
if old_piece == new_piece {
#[expect(clippy::redundant_closure_for_method_calls)]
return {
write_version_piece(old_acc, old_piece, |value| value.yellow())?;
write_version_piece(new_acc, new_piece, |value| value.yellow())
};
}
match (old_piece, new_piece) {
(&VersionPiece::Component(old_c), &VersionPiece::Component(new_c)) => {
if old_c.len() > 20
&& new_c.len() > 20
&& old_c
.chars()
.zip(new_c.chars())
.all(|(old_char, new_char)| old_char != new_char)
{
write!(old_acc, "{}", old_c.red())?;
write!(new_acc, "{}", new_c.green())?;
return Ok(());
}
let char_diffs = diff::chars(*old_c, *new_c);
let mut diff_active = false;
for res in char_diffs {
match res {
diff::Result::Both(left, right) => {
if diff_active {
write!(old_acc, "{}", left.red())?;
write!(new_acc, "{}", right.green())?;
} else {
write!(old_acc, "{}", left.yellow())?;
write!(new_acc, "{}", right.yellow())?;
}
},
diff::Result::Left(left) => {
diff_active = true;
write!(old_acc, "{}", left.red())?;
},
diff::Result::Right(right) => {
diff_active = true;
write!(new_acc, "{}", right.green())?;
},
}
}
},
#[expect(clippy::redundant_closure_for_method_calls)]
(old, new) => {
write_version_piece(old_acc, old, |value| value.red())?;
write_version_piece(new_acc, new, |value| value.green())?;
},
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::num::NonZeroUsize;
use super::*;
fn amount(amount: usize) -> NonZeroUsize {
NonZeroUsize::new(amount)
.unwrap_or_else(|| panic!("test version amount must be nonzero"))
}
fn package_diff(name: &str, status: DiffStatus) -> PackageDiff {
PackageDiff {
name: name.to_owned(),
versions: vec![VersionDiff::Changed {
old: VersionAmount::new("1.0.0", amount(1)),
new: VersionAmount::new("2.0.0", amount(1)),
}],
status,
selection: DerivationSelectionStatus::Unselected,
has_omitted_versions: false,
size: PackageSizeDelta::new(Size::from_bytes(0), Size::from_bytes(0)),
}
}
#[test]
fn render_package_diffs_sorts_alphabetically_within_status_group() {
yansi::disable();
let diffs = [
package_diff("zeta", DiffStatus::Changed),
package_diff("alpha", DiffStatus::Upgraded),
package_diff("mango", DiffStatus::Downgraded),
];
let mut output = String::new();
render_package_diffs(&mut output, &diffs).unwrap();
let alpha = output.find("[U.] alpha").unwrap();
let mango = output.find("[D.] mango").unwrap();
let zeta = output.find("[C.] zeta").unwrap();
assert!(alpha < mango);
assert!(mango < zeta);
}
#[test]
fn render_package_diffs_formats_package_size_delta_suffixes() {
yansi::disable();
let diffs = [
PackageDiff {
name: "linux-firmware".to_owned(),
versions: vec![VersionDiff::Changed {
old: VersionAmount::new("20260221", amount(1)),
new: VersionAmount::new("20260309", amount(1)),
}],
status: DiffStatus::Upgraded,
selection: DerivationSelectionStatus::Unselected,
has_omitted_versions: false,
size: PackageSizeDelta::new(
Size::from_bytes(100),
Size::from_bytes(10_000),
),
},
PackageDiff {
name: "source".to_owned(),
versions: Vec::new(),
status: DiffStatus::Changed,
selection: DerivationSelectionStatus::Unselected,
has_omitted_versions: false,
size: PackageSizeDelta::new(
Size::from_bytes(10_000),
Size::from_bytes(100),
),
},
];
let mut output = String::new();
render_package_diffs(&mut output, &diffs).unwrap();
assert!(
output.contains("[U.] linux-firmware 20260221 -> 20260309, +9.67 KiB")
);
assert!(output.contains("[C.] source -9.67 KiB"));
}
#[test]
fn fmt_version_diffs_formats_added_and_removed_amounts() {
yansi::disable();
let version_diffs = [
VersionDiff::Removed(VersionAmount::new("1.0.0", amount(2))),
VersionDiff::Added(VersionAmount::new("2.0.0", amount(3))),
];
let (old, new) = fmt_version_diffs(&version_diffs, false).unwrap();
assert_eq!(old, "1.0.0 ×2");
assert_eq!(new, "2.0.0 ×3");
}
#[test]
fn fmt_version_diffs_formats_amount_changes() {
yansi::disable();
let version_diffs = [VersionDiff::AmountChanged {
version: Version::new("1.0.0"),
old_amount: amount(1),
new_amount: amount(2),
}];
let (old, new) = fmt_version_diffs(&version_diffs, false).unwrap();
assert_eq!(old, "1.0.0");
assert_eq!(new, "1.0.0 ×2");
}
#[test]
fn write_path_stats_formats_exact_path_changes() {
yansi::disable();
let mut output = String::new();
write_path_stats(
&mut output,
PathStats::new_for_test(7529, 7536, 5054, 5047),
)
.unwrap();
assert_eq!(output, "PATHS: 7529 -> 7536 (+5054, -5047)\n");
}
}