From 07dd524d305db28d74f1238ab52c7d878d4e8ae8 Mon Sep 17 00:00:00 2001 From: RGBCube Date: Fri, 9 May 2025 15:32:21 +0300 Subject: [PATCH] feat: diff printing --- Cargo.lock | 25 +++++- Cargo.toml | 2 + src/diff.rs | 93 ++++++++++++++++++---- src/lib.rs | 2 +- src/print.rs | 206 ------------------------------------------------- src/version.rs | 66 +++++++++++++--- 6 files changed, 158 insertions(+), 236 deletions(-) delete mode 100644 src/print.rs diff --git a/Cargo.lock b/Cargo.lock index 4ee8d23..68b0ddb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 3060356..3202ba1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/src/diff.rs b/src/diff.rs index 991664b..4dc75de 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -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::; - 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: { + write!( + newacc, + " {new}", + new = new.unwrap_or(Version::ref_cast("")).green() + )?; + }, + + EitherOrBoth::Both(old, new) => { + let (old, new) = Version::diff( + old.unwrap_or(Version::ref_cast("")), + new.unwrap_or(Version::ref_cast("")), + ); + + write!(oldacc, " {old}")?; + write!(newacc, " {new}")?; + }, + + EitherOrBoth::Right(old) => { + write!( + oldacc, + " {old}", + old = old.unwrap_or(Version::ref_cast("")).red() + )?; + }, + } + } + + write!( + writer, + "{oldacc}{arrow}{newacc}", + arrow = if !oldacc.is_empty() && !newacc.is_empty() { + " →" + } else { + "" + } + )?; + writeln!(writer)?; } diff --git a/src/lib.rs b/src/lib.rs index 89cba29..3b83c13 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,7 +53,7 @@ impl StorePath { static STORE_PATH_REGEX: sync::LazyLock = 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(|| { diff --git a/src/print.rs b/src/print.rs deleted file mode 100644 index bdd3a4e..0000000 --- a/src/print.rs +++ /dev/null @@ -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::>(); - 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::>(); - 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::>(); - let mut version_vec_post = - ver_post.difference(ver_pre).copied().collect::>(); - - 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 = vec![]; - let mut diff_post: Vec = 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 = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new("(-man|-lib|-doc|-dev|-out|-terminfo)") - .expect("Failed to compile regex pattern for name") - }) -} diff --git a/src/version.rs b/src/version.rs index f69535d..86cc4c9 100644 --- a/src/version.rs +++ b/src/version.rs @@ -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 = + 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") ]); } }