diff --git a/Cargo.lock b/Cargo.lock index 1357d78..4ee8d23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,7 +73,7 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", "winapi", ] @@ -144,6 +144,16 @@ dependencies = [ "clap_derive", ] +[[package]] +name = "clap-verbosity-flag" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2678fade3b77aa3a8ff3aae87e9c008d3fb00473a41c71fbf74e91c8c7b37e84" +dependencies = [ + "clap 4.5.37", + "log", +] + [[package]] name = "clap_builder" version = "4.5.37" @@ -305,6 +315,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap 4.5.37", + "clap-verbosity-flag", "criterion", "derive_more", "diff", @@ -405,6 +416,23 @@ dependencies = [ "libc", ] +[[package]] +name = "hermit-abi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08" + +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi 0.5.1", + "libc", + "windows-sys", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1026,3 +1054,6 @@ name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +dependencies = [ + "is-terminal", +] diff --git a/Cargo.toml b/Cargo.toml index 41b881f..3060356 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,23 @@ [package] -name = "dix" -version = "0.1.0" -edition = "2024" +name = "dix" +description = "Diff Nix" +version = "0.1.0" +edition = "2024" [dependencies] -anyhow = "1.0.98" -clap = { version = "4.5.37", features = [ "derive" ] } -derive_more = { version = "2.0.1", features = ["full"] } -diff = "0.1.13" -env_logger = "0.11.3" -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" -yansi = "1.0.1" +anyhow = "1.0.98" +clap = { version = "4.5.37", features = [ "derive" ] } +clap-verbosity-flag = "3.0.2" +derive_more = { version = "2.0.1", features = [ "full" ] } +diff = "0.1.13" +env_logger = "0.11.3" +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" +yansi = { version = "1.0.1", features = [ "detect-env", "detect-tty" ] } [dev-dependencies] criterion = "0.3" diff --git a/src/diff.rs b/src/diff.rs new file mode 100644 index 0000000..dd4d612 --- /dev/null +++ b/src/diff.rs @@ -0,0 +1,101 @@ +use std::{ + fmt, + io, +}; + +use rustc_hash::{ + FxBuildHasher, + FxHashMap, +}; +use yansi::Paint as _; + +use crate::{ + StorePath, + Version, +}; + +#[derive(Default)] +struct Diff { + old: T, + new: T, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum DiffStatus { + Added, + Removed, + 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(), + }, + ) + } +} + +pub fn diff<'a>( + writer: &mut dyn io::Write, + paths_old: impl Iterator, + paths_new: impl Iterator, +) -> io::Result<()> { + let mut paths = + FxHashMap::<&str, Diff>>>::with_hasher(FxBuildHasher); + + for path in paths_old { + match path.parse_name_and_version() { + Ok((name, version)) => { + paths.entry(name).or_default().old.push(version); + }, + + Err(error) => { + log::info!("error parsing old path name and version: {error}"); + }, + } + } + + for path in paths_new { + match path.parse_name_and_version() { + Ok((name, version)) => { + paths.entry(name).or_default().new.push(version); + }, + + Err(error) => { + log::info!("error parsing new path name and version: {error}"); + }, + } + } + + let mut diffs = paths + .into_iter() + .filter_map(|(name, versions)| { + let status = match (versions.old.len(), versions.new.len()) { + (0, 0) => unreachable!(), + (0, _) => DiffStatus::Removed, + (_, 0) => DiffStatus::Added, + (..) if versions.old != versions.new => DiffStatus::Changed, + (..) => return None, + }; + + Some((name, versions, status)) + }) + .collect::>(); + + diffs.sort_by(|&(a_name, _, a_status), &(b_name, _, b_status)| { + a_status.cmp(&b_status).then_with(|| a_name.cmp(b_name)) + }); + + for (name, _versions, status) in diffs { + write!(writer, "{status} {name}")?; + writeln!(writer)?; + } + + Ok(()) +} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 85cc6c6..0000000 --- a/src/error.rs +++ /dev/null @@ -1,133 +0,0 @@ -use thiserror::Error; - -/// Application errors with thiserror -#[derive(Debug, Error)] -pub enum AppError { - #[error("Command failed: {command} {args:?} - {message}")] - CommandFailed { - command: String, - args: Vec, - message: String, - }, - - #[error("Failed to decode command output from {context}: {source}")] - CommandOutputError { - source: std::str::Utf8Error, - context: String, - }, - - #[error("Failed to parse data in {context}: {message}")] - ParseError { - message: String, - context: String, - #[source] - source: Option>, - }, - - #[error("Regex error in {context}: {source}")] - RegexError { - source: regex::Error, - context: String, - }, - - #[error("IO error in {context}: {source}")] - IoError { - source: std::io::Error, - context: String, - }, - - #[error("Database error: {source}")] - DatabaseError { source: rusqlite::Error }, -} - -// Implement From traits to support the ? operator -impl From for AppError { - fn from(source: std::io::Error) -> Self { - Self::IoError { - source, - context: "unknown context".into(), - } - } -} - -impl From for AppError { - fn from(source: std::str::Utf8Error) -> Self { - Self::CommandOutputError { - source, - context: "command output".into(), - } - } -} - -impl From for AppError { - fn from(source: rusqlite::Error) -> Self { - Self::DatabaseError { source } - } -} - -impl From for AppError { - fn from(source: regex::Error) -> Self { - Self::RegexError { - source, - context: "regex operation".into(), - } - } -} - -impl AppError { - /// Create a command failure error with context - pub fn command_failed>( - command: S, - args: &[&str], - message: S, - ) -> Self { - Self::CommandFailed { - command: command.into(), - args: args.iter().map(|&s| s.to_string()).collect(), - message: message.into(), - } - } - - /// Create a parse error with context - pub fn parse_error, C: Into>( - message: S, - context: C, - source: Option>, - ) -> Self { - Self::ParseError { - message: message.into(), - context: context.into(), - source, - } - } - - /// Create an IO error with context - pub fn io_error>(source: std::io::Error, context: C) -> Self { - Self::IoError { - source, - context: context.into(), - } - } - - /// Create a regex error with context - pub fn regex_error>( - source: regex::Error, - context: C, - ) -> Self { - Self::RegexError { - source, - context: context.into(), - } - } - - /// Create a command output error with context - pub fn command_output_error>( - source: std::str::Utf8Error, - context: C, - ) -> Self { - Self::CommandOutputError { - source, - context: context.into(), - } - } -} diff --git a/src/lib.rs b/src/lib.rs index 9779dee..89cba29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,8 +12,8 @@ use anyhow::{ }; use derive_more::Deref; -// pub mod diff; -// pub mod print; +mod diff; +pub use diff::diff; pub mod store; @@ -24,7 +24,7 @@ pub use version::Version; #[derive(Deref, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DerivationId(i64); -#[derive(Deref, Debug, Clone, PartialEq, Eq)] +#[derive(Deref, Debug, Clone, PartialEq, Eq, Hash)] pub struct StorePath(PathBuf); impl TryFrom for StorePath { diff --git a/src/main.rs b/src/main.rs index 11a2ba1..c573d1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,225 +1,229 @@ -use core::str; use std::{ - collections::HashSet, + fmt::Write as _, + io::{ + self, + Write as _, + }, + path::PathBuf, + process, thread, }; -use clap::Parser; -use dixlib::{ - print, +use anyhow::{ + Context as _, + Error, + Result, +}; +use clap::Parser as _; +use dix::{ + diff, store, - util::PackageDiff, }; -use log::{ - debug, - error, -}; -use yansi::Paint; +use yansi::Paint as _; -#[derive(Parser, Debug)] -#[command(name = "dix")] -#[command(version = "1.0")] -#[command(about = "Diff Nix stuff", long_about = None)] -#[command(version, about, long_about = None)] -struct Args { - path: std::path::PathBuf, - path2: std::path::PathBuf, +#[derive(clap::Parser, Debug)] +#[command(version, about)] +struct Cli { + old_path: PathBuf, + new_path: PathBuf, - /// Print the whole store paths - #[arg(short, long)] - paths: bool, - - /// Print the closure size - #[arg(long, short)] - closure_size: bool, - - /// Verbosity level: -v for debug, -vv for trace - #[arg(short, long, action = clap::ArgAction::Count)] - verbose: u8, - - /// Silence all output except errors - #[arg(short, long)] - quiet: bool, + #[command(flatten)] + verbose: clap_verbosity_flag::Verbosity, } -#[derive(Debug, Clone)] -struct Package<'a> { - name: &'a str, - versions: HashSet<&'a str>, - /// Save if a package is a dependency of another package - is_dep: bool, -} +fn real_main() -> Result<()> { + let Cli { + old_path, + new_path, + verbose, + } = Cli::parse(); -impl<'a> Package<'a> { - fn new(name: &'a str, version: &'a str, is_dep: bool) -> Self { - let mut versions = HashSet::new(); - versions.insert(version); - Self { - name, - versions, - is_dep, - } - } + yansi::whenever(yansi::Condition::TTY_AND_COLOR); - fn add_version(&mut self, version: &'a str) { - self.versions.insert(version); - } -} - -#[allow(clippy::cognitive_complexity, clippy::too_many_lines)] -fn main() { - let args = Args::parse(); - - // Configure logger based on verbosity flags and environment variables - // Respects RUST_LOG environment variable if present. - // XXX:We can also dedicate a specific env variable for this tool, if we want - // to. - let env = env_logger::Env::default().filter_or( - "RUST_LOG", - if args.quiet { - "error" - } else { - match args.verbose { - 0 => "info", - 1 => "debug", - _ => "trace", - } - }, - ); - - // Build and initialize the logger - env_logger::Builder::from_env(env) - .format_timestamp(Some(env_logger::fmt::TimestampPrecision::Seconds)) + env_logger::Builder::new() + .filter_level(verbose.log_level_filter()) .init(); - // handles to the threads collecting closure size information - // We do this as early as possible because nix is slow. - let closure_size_handles = if args.closure_size { - debug!("Calculating closure sizes in background"); - let path = args.path.clone(); - let path2 = args.path2.clone(); - Some(( - thread::spawn(move || store::get_closure_size(&path)), - thread::spawn(move || store::get_closure_size(&path2)), - )) - } else { - None + // Handle to the thread collecting closure size information. + // We do this as early as possible because Nix is slow. + let _closure_size_handle = { + log::debug!("calculating closure sizes in background"); + + let mut connection = store::connect()?; + + let old_path = old_path.clone(); + let new_path = new_path.clone(); + + thread::spawn(move || { + Ok::<_, Error>(( + connection.query_closure_size(&old_path)?, + connection.query_closure_size(&new_path)?, + )) + }) }; - // Get package lists and handle potential errors - let package_list_pre = match store::query_packages(&args.path) { - Ok(packages) => { - debug!("Found {} packages in first closure", packages.len()); - packages.into_iter().map(|(_, path)| path).collect() - }, - Err(e) => { - error!( - "Error getting packages from path {}: {}", - args.path.display(), - e - ); - eprintln!( - "Error getting packages from path {}: {}", - args.path.display(), - e - ); - Vec::new() - }, - }; + let mut connection = store::connect()?; - let package_list_post = match store::query_packages(&args.path2) { - Ok(packages) => { - debug!("Found {} packages in second closure", packages.len()); - packages.into_iter().map(|(_, path)| path).collect() - }, - Err(e) => { - error!( - "Error getting packages from path {}: {}", - args.path2.display(), - e - ); - eprintln!( - "Error getting packages from path {}: {}", - args.path2.display(), - e - ); - Vec::new() - }, - }; + let paths_old = + connection.query_depdendents(&old_path).with_context(|| { + format!( + "failed to query dependencies of path '{path}'", + path = old_path.display() + ) + })?; - let PackageDiff { - pkg_to_versions_pre: pre, - pkg_to_versions_post: post, - pre_keys: _, - post_keys: _, - added, - removed, - changed, - } = PackageDiff::new(&package_list_pre, &package_list_post); - - debug!("Added packages: {}", added.len()); - debug!("Removed packages: {}", removed.len()); - debug!( - "Changed packages: {}", - changed - .iter() - .filter(|p| { - !p.is_empty() - && match (pre.get(*p), post.get(*p)) { - (Some(ver_pre), Some(ver_post)) => ver_pre != ver_post, - _ => false, - } - }) - .count() + log::debug!( + "found {count} packages in old closure", + count = paths_old.len(), ); - println!("Difference between the two generations:"); - println!(); + let paths_new = + connection.query_depdendents(&new_path).with_context(|| { + format!( + "failed to query dependencies of path '{path}'", + path = new_path.display() + ) + })?; - let width_changes = changed.iter().filter(|&&p| { - match (pre.get(p), post.get(p)) { - (Some(version_pre), Some(version_post)) => version_pre != version_post, - _ => false, - } - }); + log::debug!( + "found {count} packages in new closure", + count = paths_new.len(), + ); - let col_width = added - .iter() - .chain(removed.iter()) - .chain(width_changes) - .map(|p| p.len()) - .max() - .unwrap_or_default(); + drop(connection); - println!("<<< {}", args.path.to_string_lossy()); - println!(">>> {}", args.path2.to_string_lossy()); - print::print_added(&added, &post, col_width); - print::print_removed(&removed, &pre, col_width); - print::print_changes(&changed, &pre, &post, col_width); + let mut out = io::stdout(); - if let Some((pre_handle, post_handle)) = closure_size_handles { - match (pre_handle.join(), post_handle.join()) { - (Ok(Ok(pre_size)), Ok(Ok(post_size))) => { - let pre_size = pre_size / 1024 / 1024; - let post_size = post_size / 1024 / 1024; - debug!("Pre closure size: {pre_size} MiB"); - debug!("Post closure size: {post_size} MiB"); + #[expect(clippy::pattern_type_mismatch)] + diff( + &mut out, + paths_old.iter().map(|(_, path)| path), + paths_new.iter().map(|(_, path)| path), + )?; + // let PackageDiff { + // pkg_to_versions_pre: pre, + // pkg_to_versions_post: post, + // pre_keys: _, + // post_keys: _, + // added, + // removed, + // changed, + // } = PackageDiff::new(&packages_old, &packages_after); - println!("{}", "Closure Size:".underline().bold()); - println!("Before: {pre_size} MiB"); - println!("After: {post_size} MiB"); - println!("Difference: {} MiB", post_size - pre_size); - }, - (Ok(Err(e)), _) | (_, Ok(Err(e))) => { - error!("Error getting closure size: {e}"); - eprintln!("Error getting closure size: {e}"); - }, - _ => { - error!("Failed to get closure size information due to a thread error"); - eprintln!( - "Error: Failed to get closure size information due to a thread error" - ); - }, - } - } + // log::debug!("Added packages: {}", added.len()); + // log::debug!("Removed packages: {}", removed.len()); + // log::debug!( + // "Changed packages: {}", + // changed + // .iter() + // .filter(|p| { + // !p.is_empty() + // && match (pre.get(*p), post.get(*p)) { + // (Some(ver_pre), Some(ver_post)) => ver_pre != ver_post, + // _ => false, + // } + // }) + // .count() + // ); + + // println!("Difference between the two generations:"); + // println!(); + + // let width_changes = changed.iter().filter(|&&p| { + // match (pre.get(p), post.get(p)) { + // (Some(version_pre), Some(version_post)) => version_pre != version_post, + // _ => false, + // } + // }); + + // let col_width = added + // .iter() + // .chain(removed.iter()) + // .chain(width_changes) + // .map(|p| p.len()) + // .max() + // .unwrap_or_default(); + + // println!("<<< {}", cli.path.to_string_lossy()); + // println!(">>> {}", cli.path2.to_string_lossy()); + // print::print_added(&added, &post, col_width); + // print::print_removed(&removed, &pre, col_width); + // print::print_changes(&changed, &pre, &post, col_width); + + // if let Some((pre_handle, post_handle)) = closure_size_handles { + // match (pre_handle.join(), post_handle.join()) { + // (Ok(Ok(pre_size)), Ok(Ok(post_size))) => { + // let pre_size = pre_size / 1024 / 1024; + // let post_size = post_size / 1024 / 1024; + // log::debug!("Pre closure size: {pre_size} MiB"); + // log::debug!("Post closure size: {post_size} MiB"); + + // println!("{}", "Closure Size:".underline().bold()); + // println!("Before: {pre_size} MiB"); + // println!("After: {post_size} MiB"); + // println!("Difference: {} MiB", post_size - pre_size); + // }, + // (Ok(Err(e)), _) | (_, Ok(Err(e))) => { + // log::error!("Error getting closure size: {e}"); + // eprintln!("Error getting closure size: {e}"); + // }, + // _ => { + // log::error!( + // "Failed to get closure size information due to a thread error" + // ); + // eprintln!( + // "Error: Failed to get closure size information due to a thread + // error" ); + // }, + // } + // } + + Ok(()) +} + +#[allow(clippy::allow_attributes, clippy::exit)] +fn main() { + let Err(error) = real_main() else { + return; + }; + + let mut err = io::stderr(); + + let mut message = String::new(); + let mut chain = error.chain().rev().peekable(); + + while let Some(error) = chain.next() { + let _ = write!( + err, + "{header} ", + header = if chain.peek().is_none() { + "error:" + } else { + "cause:" + } + .red() + .bold(), + ); + + String::clear(&mut message); + let _ = write!(message, "{error}"); + + let mut chars = message.char_indices(); + + let _ = if let Some((_, first)) = chars.next() + && let Some((second_start, second)) = chars.next() + && second.is_lowercase() + { + writeln!( + err, + "{first_lowercase}{rest}", + first_lowercase = first.to_lowercase(), + rest = &message[second_start..], + ) + } else { + writeln!(err, "{message}") + }; + } + + process::exit(1); } diff --git a/src/print.rs b/src/print.rs index 16f6aad..bdd3a4e 100644 --- a/src/print.rs +++ b/src/print.rs @@ -200,7 +200,7 @@ pub fn print_changes( fn name_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); REGEX.get_or_init(|| { - Regex::new(r"(-man|-lib|-doc|-dev|-out|-terminfo)") + Regex::new("(-man|-lib|-doc|-dev|-out|-terminfo)") .expect("Failed to compile regex pattern for name") }) } diff --git a/src/store.rs b/src/store.rs index f2fd957..a0d9422 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,4 +1,7 @@ -use std::result; +use std::{ + path::Path, + result, +}; use anyhow::{ Context as _, @@ -48,10 +51,36 @@ pub fn connect() -> Result { } impl Connection { - /// Gathers all derivations that the given store path depends on. + /// Gets the total closure size of the given store path by summing up the nar + /// size of all depdendent derivations. + pub fn query_closure_size(&mut self, path: &Path) -> Result { + const QUERY: &str = " + WITH RECURSIVE + graph(p) AS ( + SELECT id + FROM ValidPaths + WHERE path = ? + UNION + SELECT reference FROM Refs + JOIN graph ON referrer = p + ) + SELECT SUM(narSize) as sum from graph + JOIN ValidPaths ON p = id; + "; + + path_to_str!(path); + + let closure_size = self + .prepare_cached(QUERY)? + .query_row([path], |row| row.get(0))?; + + Ok(closure_size) + } + + /// Gathers all derivations that the given profile path depends on. pub fn query_depdendents( &mut self, - path: &StorePath, + path: &Path, ) -> Result> { const QUERY: &str = " WITH RECURSIVE @@ -82,32 +111,6 @@ impl Connection { Ok(packages?) } - /// Gets the total closure size of the given store path by summing up the nar - /// size of all depdendent derivations. - pub fn query_closure_size(&mut self, path: &StorePath) -> Result { - const QUERY: &str = " - WITH RECURSIVE - graph(p) AS ( - SELECT id - FROM ValidPaths - WHERE path = ? - UNION - SELECT reference FROM Refs - JOIN graph ON referrer = p - ) - SELECT SUM(narSize) as sum from graph - JOIN ValidPaths ON p = id; - "; - - path_to_str!(path); - - let closure_size = self - .prepare_cached(QUERY)? - .query_row([path], |row| row.get(0))?; - - Ok(closure_size) - } - /// Gathers the complete dependency graph of of the store path as an adjacency /// list. ///