1
Fork 0
mirror of https://github.com/RGBCube/dix synced 2025-07-31 21:57:46 +00:00

feat: diff printing

This commit is contained in:
RGBCube 2025-05-09 15:32:21 +03:00 committed by bloxx12
parent 47bd2d9657
commit 07dd524d30
6 changed files with 158 additions and 236 deletions

25
Cargo.lock generated
View file

@ -131,7 +131,7 @@ checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
dependencies = [
"bitflags 1.3.2",
"textwrap",
"unicode-width",
"unicode-width 0.1.14",
]
[[package]]
@ -210,7 +210,7 @@ dependencies = [
"clap 2.34.0",
"criterion-plot",
"csv",
"itertools",
"itertools 0.10.5",
"lazy_static",
"num-traits",
"oorandom",
@ -232,7 +232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2673cc8207403546f45f5fd319a974b1e6983ad1a3ee7e6041650013be041876"
dependencies = [
"cast",
"itertools",
"itertools 0.10.5",
]
[[package]]
@ -320,6 +320,7 @@ dependencies = [
"derive_more",
"diff",
"env_logger",
"itertools 0.14.0",
"libc",
"log",
"ref-cast",
@ -327,6 +328,7 @@ dependencies = [
"rusqlite",
"rustc-hash",
"thiserror",
"unicode-width 0.2.0",
"yansi",
]
@ -448,6 +450,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
@ -798,7 +809,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
"unicode-width 0.1.14",
]
[[package]]
@ -849,6 +860,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unicode-xid"
version = "0.2.6"

View file

@ -11,12 +11,14 @@ clap-verbosity-flag = "3.0.2"
derive_more = { version = "2.0.1", features = [ "full" ] }
diff = "0.1.13"
env_logger = "0.11.3"
itertools = "0.14.0"
log = "0.4.20"
ref-cast = "1.0.24"
regex = "1.11.1"
rusqlite = { version = "0.35.0", features = [ "bundled" ] }
rustc-hash = "2.1.1"
thiserror = "2.0.12"
unicode-width = "0.2.0"
yansi = { version = "1.0.1", features = [ "detect-env", "detect-tty" ] }
[dev-dependencies]

View file

@ -1,9 +1,15 @@
use std::fmt;
use std::fmt::{
self,
Write as _,
};
use itertools::EitherOrBoth;
use ref_cast::RefCast as _;
use rustc_hash::{
FxBuildHasher,
FxHashMap,
};
use unicode_width::UnicodeWidthStr as _;
use yansi::Paint as _;
use crate::{
@ -26,17 +32,13 @@ enum DiffStatus {
Changed,
}
impl fmt::Display for DiffStatus {
fn fmt(&self, writer: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
writer,
"[{letter}]",
letter = match *self {
DiffStatus::Added => "A".green(),
DiffStatus::Removed => "R".red(),
DiffStatus::Changed => "C".yellow(),
},
)
impl DiffStatus {
fn char(self) -> impl fmt::Display {
match self {
DiffStatus::Added => "A".green(),
DiffStatus::Removed => "R".red(),
DiffStatus::Changed => "C".yellow(),
}
}
}
@ -74,7 +76,10 @@ pub fn diff<'a>(
let mut diffs = paths
.into_iter()
.filter_map(|(name, versions)| {
.filter_map(|(name, mut versions)| {
versions.old.sort_unstable();
versions.new.sort_unstable();
let status = match (versions.old.len(), versions.new.len()) {
(0, 0) => unreachable!(),
(0, _) => DiffStatus::Removed,
@ -91,9 +96,15 @@ pub fn diff<'a>(
a_status.cmp(&b_status).then_with(|| a_name.cmp(b_name))
});
let name_width = diffs
.iter()
.map(|&(name, ..)| name.width())
.max()
.unwrap_or(0);
let mut last_status = None::<DiffStatus>;
for (name, _versions, status) in diffs {
for (name, versions, status) in diffs {
if last_status != Some(status) {
last_status = Some(status);
HEADER_STYLE.fmt_prefix(writer)?;
@ -101,7 +112,59 @@ pub fn diff<'a>(
HEADER_STYLE.fmt_suffix(writer)?;
}
write!(writer, "{status} {name}")?;
write!(
writer,
"[{status}] {name:<name_width$}",
status = status.char()
)?;
let mut oldacc = String::new();
let mut newacc = String::new();
for diff in itertools::Itertools::zip_longest(
versions.old.iter(),
versions.new.iter(),
) {
match diff {
// I have no idea why itertools is returning `versions.new` in `Left`.
EitherOrBoth::Left(new) => {
write!(
newacc,
" {new}",
new = new.unwrap_or(Version::ref_cast("<none>")).green()
)?;
},
EitherOrBoth::Both(old, new) => {
let (old, new) = Version::diff(
old.unwrap_or(Version::ref_cast("<none>")),
new.unwrap_or(Version::ref_cast("<none>")),
);
write!(oldacc, " {old}")?;
write!(newacc, " {new}")?;
},
EitherOrBoth::Right(old) => {
write!(
oldacc,
" {old}",
old = old.unwrap_or(Version::ref_cast("<none>")).red()
)?;
},
}
}
write!(
writer,
"{oldacc}{arrow}{newacc}",
arrow = if !oldacc.is_empty() && !newacc.is_empty() {
""
} else {
""
}
)?;
writeln!(writer)?;
}

View file

@ -53,7 +53,7 @@ impl StorePath {
static STORE_PATH_REGEX: sync::LazyLock<regex::Regex> =
sync::LazyLock::new(|| {
regex::Regex::new("(.+?)(-([0-9].*?))?$")
.expect("failed to compile regex pattern for nix store paths")
.expect("failed to compile regex for Nix store paths")
});
let path = self.to_str().with_context(|| {

View file

@ -1,206 +0,0 @@
use core::str;
use std::{
collections::{
HashMap,
HashSet,
},
string::ToString,
sync::OnceLock,
};
use regex::Regex;
use yansi::Paint;
/// diffs two strings character by character, and returns a tuple of strings
/// colored in a way to represent the differences between the two input strings.
///
/// # Returns:
///
/// * (String, String) - The differing chars being red in the left, and green in
/// the right one.
fn diff_versions(left: &str, right: &str) -> (String, String) {
let mut prev = "\x1b[33m".to_string();
let mut post = "\x1b[33m".to_string();
// We only have to filter the left once, since we stop if the left one is
// empty. We do this to display things like -man, -dev properly.
let matches = name_regex().captures(left);
let mut suffix = String::new();
if let Some(m) = matches {
let tmp = m.get(0).map_or("", |m| m.as_str());
suffix.push_str(tmp);
}
// string without the suffix
let filtered_left = &left[..left.len() - suffix.len()];
let filtered_right = &right[..right.len() - suffix.len()];
for diff in diff::chars(filtered_left, filtered_right) {
match diff {
diff::Result::Both(l, _) => {
let string_to_push = format!("{l}");
prev.push_str(&string_to_push);
post.push_str(&string_to_push);
},
diff::Result::Left(l) => {
let string_to_push = format!("\x1b[1;91m{l}");
prev.push_str(&string_to_push);
},
diff::Result::Right(r) => {
let string_to_push = format!("\x1b[1;92m{r}");
post.push_str(&string_to_push);
},
}
}
// push removed suffix
prev.push_str(&format!("\x1b[33m{}", &suffix));
post.push_str(&format!("\x1b[33m{}", &suffix));
// reset
prev.push_str("\x1b[0m");
post.push_str("\x1b[0m");
(prev, post)
}
/// print the packages added between two closures.
pub fn print_added(
set: &HashSet<&str>,
post: &HashMap<&str, HashSet<&str>>,
col_width: usize,
) {
println!("{}", "Packages added:".underline().bold());
// Use sorted outpu
let mut sorted: Vec<_> = set
.iter()
.filter_map(|p| post.get(p).map(|ver| (*p, ver)))
.collect();
// Sort by package name for consistent output
sorted.sort_by(|(a, _), (b, _)| a.cmp(b));
for (p, ver) in sorted {
let mut version_vec = ver.iter().copied().collect::<Vec<_>>();
version_vec.sort_unstable();
let version_str = version_vec.join(", ");
println!(
"[{}] {:col_width$} \x1b[33m{}\x1b[0m",
"A:".green().bold(),
p,
version_str
);
}
}
/// print the packages removed between two closures.
pub fn print_removed(
set: &HashSet<&str>,
pre: &HashMap<&str, HashSet<&str>>,
col_width: usize,
) {
println!("{}", "Packages removed:".underline().bold());
// Use sorted output for more predictable and readable results
let mut sorted: Vec<_> = set
.iter()
.filter_map(|p| pre.get(p).map(|ver| (*p, ver)))
.collect();
// Sort by package name for consistent output
sorted.sort_by(|(a, _), (b, _)| a.cmp(b));
for (p, ver) in sorted {
let mut version_vec = ver.iter().copied().collect::<Vec<_>>();
version_vec.sort_unstable();
let version_str = version_vec.join(", ");
println!(
"[{}] {:col_width$} \x1b[33m{}\x1b[0m",
"R:".red().bold(),
p,
version_str
);
}
}
pub fn print_changes(
set: &HashSet<&str>,
pre: &HashMap<&str, HashSet<&str>>,
post: &HashMap<&str, HashSet<&str>>,
col_width: usize,
) {
println!("{}", "Versions changed:".underline().bold());
// Use sorted output for more predictable and readable results
let mut changes = Vec::new();
for p in set.iter().filter(|p| !p.is_empty()) {
if let (Some(ver_pre), Some(ver_post)) = (pre.get(p), post.get(p)) {
if ver_pre != ver_post {
changes.push((*p, ver_pre, ver_post));
}
}
}
// Sort by package name for consistent output
changes.sort_by(|(a, ..), (b, ..)| a.cmp(b));
for (p, ver_pre, ver_post) in changes {
let mut version_vec_pre =
ver_pre.difference(ver_post).copied().collect::<Vec<_>>();
let mut version_vec_post =
ver_post.difference(ver_pre).copied().collect::<Vec<_>>();
version_vec_pre.sort_unstable();
version_vec_post.sort_unstable();
let mut diffed_pre: String;
let diffed_post: String;
if version_vec_pre.len() == version_vec_post.len() {
let mut diff_pre: Vec<String> = vec![];
let mut diff_post: Vec<String> = vec![];
for (pre, post) in version_vec_pre.iter().zip(version_vec_post.iter()) {
let (a, b) = diff_versions(pre, post);
diff_pre.push(a);
diff_post.push(b);
}
diffed_pre = diff_pre.join(", ");
diffed_post = diff_post.join(", ");
} else {
let version_str_pre = version_vec_pre.join(", ");
let version_str_post = version_vec_post.join(", ");
(diffed_pre, diffed_post) =
diff_versions(&version_str_pre, &version_str_post);
}
// push a space to the diffed_pre, if it is non-empty, we do this here and
// not in the println in order to properly align the ±.
if !version_vec_pre.is_empty() {
let mut tmp = " ".to_string();
tmp.push_str(&diffed_pre);
diffed_pre = tmp;
}
println!(
"[{}] {:col_width$}{} \x1b[0m\u{00B1}\x1b[0m {}",
"C:".bold().bright_yellow(),
p,
diffed_pre,
diffed_post
);
}
}
// Returns a reference to the compiled regex pattern.
// The regex is compiled only once.
fn name_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new("(-man|-lib|-doc|-dev|-out|-terminfo)")
.expect("Failed to compile regex pattern for name")
})
}

View file

@ -1,13 +1,19 @@
use std::cmp;
use std::{
cmp,
fmt::Write as _,
sync,
};
use derive_more::{
Deref,
DerefMut,
Display,
From,
};
use ref_cast::RefCast;
use yansi::Paint as _;
#[derive(RefCast, Deref, Debug, PartialEq, Eq)]
#[derive(RefCast, Deref, Display, Debug, PartialEq, Eq)]
#[repr(transparent)]
pub struct Version(str);
@ -26,7 +32,47 @@ impl cmp::Ord for Version {
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
impl Version {
pub fn diff(old: &Version, new: &Version) -> (String, String) {
static NAME_SUFFIX_REGEX: sync::LazyLock<regex::Regex> =
sync::LazyLock::new(|| {
regex::Regex::new("(-man|-lib|-doc|-dev|-out|-terminfo)")
.expect("failed to compile regex for Nix store path versions")
});
let matches = NAME_SUFFIX_REGEX.captures(old);
let suffix = matches.map_or("", |matches| {
matches.get(0).map_or("", |capture| capture.as_str())
});
let old = old.strip_suffix(suffix).unwrap_or(old);
let new = new.strip_suffix(suffix).unwrap_or(new);
let mut oldacc = String::new();
let mut newacc = String::new();
for diff in diff::chars(old, new) {
match diff {
diff::Result::Left(oldc) => {
write!(oldacc, "{oldc}", oldc = oldc.red()).unwrap();
},
diff::Result::Both(oldc, newc) => {
write!(oldacc, "{oldc}", oldc = oldc.yellow()).unwrap();
write!(newacc, "{newc}", newc = newc.yellow()).unwrap();
},
diff::Result::Right(newc) => {
write!(newacc, "{newc}", newc = newc.green()).unwrap();
},
}
}
(oldacc, newacc)
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum VersionComponent<'a> {
Number(u64),
Text(&'a str),
@ -45,10 +91,10 @@ impl cmp::Ord for VersionComponent<'_> {
Text,
};
match (self, other) {
(Number(x), Number(y)) => x.cmp(y),
match (*self, *other) {
(Number(x), Number(y)) => x.cmp(&y),
(Text(x), Text(y)) => {
match (*x, *y) {
match (x, y) {
("pre", _) => cmp::Ordering::Less,
(_, "pre") => cmp::Ordering::Greater,
_ => x.cmp(y),
@ -79,8 +125,8 @@ impl<'a> Iterator for VersionComponentIter<'a> {
// Based on this collect characters after this into the component.
let component_len = self
.chars()
.take_while(|&c| {
c.is_ascii_digit() == is_digit && !matches!(c, '.' | '-')
.take_while(|&char| {
char.is_ascii_digit() == is_digit && !matches!(char, '.' | '-')
})
.map(char::len_utf8)
.sum();
@ -116,10 +162,10 @@ mod tests {
Number(132),
Number(1),
Number(2),
Text("test".into()),
Text("test"),
Number(234),
Number(1),
Text("man".into())
Text("man")
]);
}
}