diff --git a/src/app.rs b/src/app.rs index 4ad14aa5..8e71d4c8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -38,7 +38,7 @@ impl App { } /// Run the application. - pub fn run(mut self) -> Result<(), RuntimeError> { + pub fn run(self) -> Result<(), RuntimeError> { // DYNAMIC DISPATCH POLICY: // // Errors rarely occur, therefore, using dynamic dispatch to report errors have an acceptable @@ -156,11 +156,7 @@ impl App { } if cfg!(unix) && self.args.deduplicate_hardlinks && self.args.files.len() > 1 { - // Hardlinks deduplication doesn't work properly if there are more than 1 paths pointing to - // the same tree or if a path points to a subtree of another path. Therefore, we must find - // and remove such overlapping paths before they cause problems. - use overlapping_arguments::{remove_overlapping_paths, RealApi}; - remove_overlapping_paths::(&mut self.args.files); + return Err(RuntimeError::DeduplicateHardlinkMultipleArgs); } let report_error = if self.args.silent_errors { @@ -331,4 +327,3 @@ impl App { mod hdd; mod mount_point; -mod overlapping_arguments; diff --git a/src/app/overlapping_arguments.rs b/src/app/overlapping_arguments.rs deleted file mode 100644 index f452bb06..00000000 --- a/src/app/overlapping_arguments.rs +++ /dev/null @@ -1,126 +0,0 @@ -use pipe_trait::Pipe; -use std::{ - collections::HashSet, - fs::{canonicalize, symlink_metadata}, - io, - mem::take, - path::PathBuf, -}; - -/// Mockable APIs to interact with the system. -pub trait Api { - type Argument; - type RealPath: Eq; - type RealPathError; - fn canonicalize(path: &Self::Argument) -> Result; - fn is_real_dir(path: &Self::Argument) -> bool; - fn starts_with(a: &Self::RealPath, b: &Self::RealPath) -> bool; -} - -/// Implementation of [`Api`] that interacts with the real system. -pub struct RealApi; -impl Api for RealApi { - type Argument = PathBuf; - type RealPath = PathBuf; - type RealPathError = io::Error; - - #[inline] - fn canonicalize(path: &Self::Argument) -> Result { - canonicalize(path) - } - - #[inline] - fn is_real_dir(path: &Self::Argument) -> bool { - path.pipe(symlink_metadata) - .is_ok_and(|metadata| !metadata.is_symlink() && metadata.is_dir()) - } - - #[inline] - fn starts_with(a: &Self::RealPath, b: &Self::RealPath) -> bool { - a.starts_with(b) - } -} - -/// Hardlinks deduplication doesn't work properly if there are more than 1 paths pointing to -/// the same tree or if a path points to a subtree of another path. Therefore, we must find -/// and remove such overlapping paths before they cause problems. -pub fn remove_overlapping_paths(arguments: &mut Vec) { - let to_remove = find_overlapping_paths_to_remove::(arguments); - remove_items_from_vec_by_indices(arguments, &to_remove); -} - -/// Find overlapping paths in a list of arguments to remove and return their indices. -/// -/// Prefer keeping the containing tree over the subtree (returning the index of the subtree). -/// -/// Prefer keeping the first instance of the path over the later instances (returning the indices of -/// the later instances). -pub fn find_overlapping_paths_to_remove( - arguments: &[Api::Argument], -) -> HashSet { - let real_paths: Vec<_> = arguments - .iter() - .map(|path| { - Api::is_real_dir(path) - .then(|| Api::canonicalize(path)) - .and_then(Result::ok) - }) - .collect(); - assert_eq!(arguments.len(), real_paths.len()); - - let mut to_remove = HashSet::new(); - for left_index in 0..arguments.len() { - for right_index in (left_index + 1)..arguments.len() { - if let (Some(left), Some(right)) = (&real_paths[left_index], &real_paths[right_index]) { - // both paths are the same, remove the second one - if left == right { - to_remove.insert(right_index); - continue; - } - - // `left` starts with `right` means `left` is subtree of `right`, remove `left` - if Api::starts_with(left, right) { - to_remove.insert(left_index); - continue; - } - - // `right` starts with `left` means `right` is subtree of `left`, remove `right` - if Api::starts_with(right, left) { - to_remove.insert(right_index); - continue; - } - } - } - } - to_remove -} - -/// Remove elements from a vector by indices. -pub fn remove_items_from_vec_by_indices(vec: &mut Vec, indices: &HashSet) { - // Optimization: If there is no element to remove then there is nothing to do. - if indices.is_empty() { - return; - } - - // Optimization: If there is only 1 element to remove, shifting elements would be cheaper than reallocating a whole array. - if indices.len() == 1 { - let index = *indices.iter().next().unwrap(); - vec.remove(index); - return; - } - - // Default: If there are more than 1 element to remove, just copy the whole array without them. - *vec = vec - .pipe(take) - .into_iter() - .enumerate() - .filter(|(index, _)| !indices.contains(index)) - .map(|(_, item)| item) - .collect(); -} - -#[cfg(test)] -mod test_remove_items_from_vec_by_indices; -#[cfg(unix)] -#[cfg(test)] -mod test_remove_overlapping_paths; diff --git a/src/app/overlapping_arguments/test_remove_items_from_vec_by_indices.rs b/src/app/overlapping_arguments/test_remove_items_from_vec_by_indices.rs deleted file mode 100644 index b2aae115..00000000 --- a/src/app/overlapping_arguments/test_remove_items_from_vec_by_indices.rs +++ /dev/null @@ -1,31 +0,0 @@ -use super::remove_items_from_vec_by_indices; -use maplit::hashset; -use pretty_assertions::assert_eq; -use std::collections::HashSet; - -#[test] -fn remove_nothing() { - let original = vec![31, 54, 22, 81, 67, 45, 52, 20, 85, 66, 27, 84]; - let mut modified = original.clone(); - remove_items_from_vec_by_indices(&mut modified, &HashSet::new()); - assert_eq!(modified, original); -} - -#[test] -fn remove_single() { - let original = vec![31, 54, 22, 81, 67, 45, 52, 20, 85, 66, 27, 84]; - let mut modified = original.clone(); - remove_items_from_vec_by_indices(&mut modified, &hashset! { 3 }); - assert_eq!(&modified[..3], &original[..3]); - assert_eq!(&modified[3..], &original[4..]); -} - -#[test] -fn remove_multiple() { - let original = vec![31, 54, 22, 81, 67, 45, 52, 20, 85, 66, 27, 84]; - let mut modified = original.clone(); - remove_items_from_vec_by_indices(&mut modified, &hashset! { 3, 4, 5, 7 }); - assert_eq!(&modified[..3], &original[..3]); - assert_eq!(&modified[3..4], &original[6..7]); - assert_eq!(&modified[4..], &original[8..]); -} diff --git a/src/app/overlapping_arguments/test_remove_overlapping_paths.rs b/src/app/overlapping_arguments/test_remove_overlapping_paths.rs deleted file mode 100644 index 7218fdb5..00000000 --- a/src/app/overlapping_arguments/test_remove_overlapping_paths.rs +++ /dev/null @@ -1,256 +0,0 @@ -use super::{remove_overlapping_paths, Api}; -use normalize_path::NormalizePath; -use pipe_trait::Pipe; -use pretty_assertions::assert_eq; -use std::{convert::Infallible, path::PathBuf}; - -const MOCKED_CURRENT_DIR: &str = "/home/user/current-dir"; - -const MOCKED_SYMLINKS: &[(&str, &str)] = &[ - ("/home/user/current-dir/link-to-current-dir", "."), - ("/home/user/current-dir/link-to-parent-dir", ".."), - ("/home/user/current-dir/link-to-root", "/"), - ("/home/user/current-dir/link-to-bin", "/usr/bin"), - ("/home/user/current-dir/link-to-foo", "foo"), - ("/home/user/current-dir/link-to-bar", "bar"), - ("/home/user/current-dir/link-to-012", "0/1/2"), -]; - -fn resolve_symlink(absolute_path: PathBuf) -> PathBuf { - assert!( - absolute_path.is_absolute(), - "absolute_path should be absolute: {absolute_path:?}", - ); - for &(link_path, link_target) in MOCKED_SYMLINKS { - let link_path = PathBuf::from(link_path); - assert!( - link_path.is_absolute(), - "link_path should be absolute: {link_path:?}", - ); - let Some(parent) = link_path.parent() else { - panic!("Cannot get parent of {link_path:?}"); - }; - if let Ok(suffix) = absolute_path.strip_prefix(&link_path) { - return parent - .join(link_target) - .join(suffix) - .normalize() - .pipe(resolve_symlink); - } - } - absolute_path -} - -/// Mocked implementation of [`Api`] for testing purposes. -struct MockedApi; -impl Api for MockedApi { - type Argument = &'static str; - type RealPath = PathBuf; - type RealPathError = Infallible; - - fn canonicalize(path: &Self::Argument) -> Result { - MOCKED_CURRENT_DIR - .pipe(PathBuf::from) - .join(path) - .normalize() - .pipe(resolve_symlink) - .pipe(Ok) - } - - fn is_real_dir(path: &Self::Argument) -> bool { - let path = MOCKED_CURRENT_DIR.pipe(PathBuf::from).join(path); - MOCKED_SYMLINKS - .iter() - .all(|(link, _)| PathBuf::from(link).normalize() != path) - } - - fn starts_with(a: &Self::RealPath, b: &Self::RealPath) -> bool { - a.starts_with(b) - } -} - -#[test] -fn remove_nothing() { - let original = vec!["foo", "bar", "abc/def", "0/1/2"]; - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = original; - assert_eq!(actual, expected); -} - -#[test] -fn remove_duplicated_arguments() { - let original = dbg!(vec![ - "foo", - "bar", - "abc/def", - "foo", - "0/1/2", - "./bar", - "./abc/./def", - ]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = vec!["foo", "bar", "abc/def", "0/1/2"]; - assert_eq!(actual, expected); - - let original = dbg!(vec![ - "foo", - "./bar", - "bar", - "./abc/./def", - "abc/def", - "foo", - "0/1/2", - ]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = vec!["foo", "./bar", "./abc/./def", "0/1/2"]; - assert_eq!(actual, expected); -} - -#[test] -fn remove_overlapping_sub_paths() { - let original = vec![ - "foo/child", - "foo", - "bar", - "abc/def", - "0/1/2", - "bar/child", - "0/1/2/3", - ]; - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = vec!["foo", "bar", "abc/def", "0/1/2"]; - assert_eq!(actual, expected); -} - -#[test] -fn remove_all_except_current_dir() { - let original = dbg!(vec!["foo", "bar", ".", "abc/def", "0/1/2"]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = vec!["."]; - assert_eq!(actual, expected); - - let original = dbg!(vec![ - "foo", - "bar", - ".", - "abc/def", - "0/1/2", - MOCKED_CURRENT_DIR, - ]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = vec!["."]; - assert_eq!(actual, expected); - - let original = dbg!(vec![ - "foo", - "bar", - MOCKED_CURRENT_DIR, - ".", - "abc/def", - "0/1/2", - ]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = vec![MOCKED_CURRENT_DIR]; - assert_eq!(actual, expected); -} - -#[test] -fn remove_all_except_parent_dir() { - let original = dbg!(vec!["foo", "bar", "..", "abc/def", ".", "0/1/2"]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = vec![".."]; - assert_eq!(actual, expected); - - let original = dbg!(vec![ - "foo", - "/home/user", - "bar", - "..", - "abc/def", - ".", - "0/1/2", - ]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = vec!["/home/user"]; - assert_eq!(actual, expected); -} - -#[test] -fn remove_overlapping_real_paths() { - let original = dbg!(vec![ - "foo", - "bar", - "abc/def", - "link-to-foo/child", - "link-to-bar/a/b/c", - "0/1/2", - ]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = vec!["foo", "bar", "abc/def", "0/1/2"]; - assert_eq!(actual, expected); - - let original = dbg!(vec![ - "link-to-foo/child", - "link-to-bar/a/b/c", - "foo", - "bar", - "abc/def", - "0/1/2", - ]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = vec!["foo", "bar", "abc/def", "0/1/2"]; - assert_eq!(actual, expected); - - let original = dbg!(vec![ - "link-to-current-dir/foo", - "foo", - "bar", - "abc/def", - "link-to-current-dir/bar", - "0/1/2", - ]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = vec!["link-to-current-dir/foo", "bar", "abc/def", "0/1/2"]; - assert_eq!(actual, expected); -} - -#[test] -fn do_not_remove_symlinks() { - let original = dbg!(vec![ - "foo", - "bar", - "abc/def", - "link-to-foo", - "link-to-bar", - "0/1/2", - ]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = original; - assert_eq!(actual, expected); - - let original = dbg!(vec![ - "foo/child", - "bar", - "abc/def", - "link-to-foo", - "link-to-bar", - "0/1/2", - ]); - let mut actual = original.clone(); - remove_overlapping_paths::(&mut actual); - let expected = original; - assert_eq!(actual, expected); -} diff --git a/src/runtime_error.rs b/src/runtime_error.rs index dba94c00..987a9db1 100644 --- a/src/runtime_error.rs +++ b/src/runtime_error.rs @@ -22,6 +22,10 @@ pub enum RuntimeError { /// When the user attempts to use unavailable platform-specific features. #[display("UnsupportedFeature: {_0}")] UnsupportedFeature(UnsupportedFeature), + /// When `--deduplicate-hardlinks` is used with more than 1 argument. + #[cfg(unix)] + #[display("DeduplicateHardlinkMultipleArgs: --deduplicate-hardlinks cannot be used with multiple arguments")] + DeduplicateHardlinkMultipleArgs, } /// Error caused by the user attempting to use unavailable platform-specific features. @@ -49,6 +53,8 @@ impl RuntimeError { RuntimeError::JsonInputArgConflict => 4, RuntimeError::InvalidInputReflection(_) => 5, RuntimeError::UnsupportedFeature(_) => 6, + #[cfg(unix)] + RuntimeError::DeduplicateHardlinkMultipleArgs => 7, }) } } diff --git a/tests/hardlinks_deduplication_multi_args.rs b/tests/hardlinks_deduplication_multi_args.rs index 4851aff1..5af32f8c 100644 --- a/tests/hardlinks_deduplication_multi_args.rs +++ b/tests/hardlinks_deduplication_multi_args.rs @@ -5,26 +5,9 @@ pub mod _utils; pub use _utils::*; use command_extra::CommandExtra; -use into_sorted::IntoSorted; -use itertools::Itertools; -use parallel_disk_usage::{ - bytes_format::BytesFormat, - data_tree::Reflection, - hardlink::{ - hardlink_list::{reflection::ReflectionEntry, Summary}, - LinkPathListReflection, - }, - inode::InodeNumber, - json_data::{JsonData, JsonTree}, - size::Bytes, -}; use pipe_trait::Pipe; use pretty_assertions::assert_eq; -use std::{ - collections::HashSet, - path::PathBuf, - process::{Command, Stdio}, -}; +use std::process::{Command, Stdio}; fn stdio(command: Command) -> Command { command @@ -33,589 +16,50 @@ fn stdio(command: Command) -> Command { .with_stderr(Stdio::piped()) } -#[test] -fn simple_tree_with_some_hardlinks() { - #![expect(clippy::identity_op)] +const EXPECTED_ERROR: &str = + "[error] DeduplicateHardlinkMultipleArgs: --deduplicate-hardlinks cannot be used with multiple arguments"; +#[test] +fn two_args() { let sizes = [200_000, 220_000, 310_000, 110_000, 210_000]; let workspace = SampleWorkspace::simple_tree_with_some_hardlinks(sizes); - let mut tree = Command::new(PDU) + let output = Command::new(PDU) .with_current_dir(&workspace) .with_arg("--quantity=apparent-size") .with_arg("--deduplicate-hardlinks") - .with_arg("--json-output") .with_arg("main/sources") .with_arg("main/internal-hardlinks") .pipe(stdio) .output() - .expect("spawn command") - .pipe(stdout_text) - .pipe_as_ref(serde_json::from_str::) - .expect("parse stdout as JsonData") - .body - .pipe(JsonTree::::try_from) - .expect("get tree of bytes"); - sort_reflection_by(&mut tree, |a, b| a.name.cmp(&b.name)); - let tree = tree; - - let file_size = |name: &str| { - workspace - .join("main/sources") - .join(name) - .pipe_as_ref(read_apparent_size) - .pipe(Bytes::new) - }; - - let inode_size = |path: &str| { - workspace - .join(path) - .pipe_as_ref(read_apparent_size) - .pipe(Bytes::new) - }; - - let file_inode = |name: &str| { - workspace - .join("main/sources") - .join(name) - .pipe_as_ref(read_inode_number) - .pipe(InodeNumber::from) - }; - - let shared_paths = |suffices: &[&str]| { - suffices - .iter() - .map(|suffix| PathBuf::from("main").join(suffix)) - .collect::>() - .pipe(LinkPathListReflection) - }; - - let actual_size = tree.size; - let expected_size = Bytes::new(0) - + inode_size("main/sources") - + inode_size("main/internal-hardlinks") - + file_size("no-hardlinks.txt") - + file_size("one-internal-hardlink.txt") - + file_size("two-internal-hardlinks.txt") - + file_size("one-external-hardlink.txt") - + file_size("one-internal-one-external-hardlinks.txt"); - assert_eq!(actual_size, expected_size); + .expect("spawn command"); - let actual_tree = &tree.tree; - let expected_tree = { - let mut tree = Command::new(PDU) - .with_current_dir(&workspace) - .with_arg("--quantity=apparent-size") - .with_arg("--deduplicate-hardlinks") - .with_arg("--json-output") - .with_arg("main") - .pipe(stdio) - .output() - .expect("spawn command") - .pipe(stdout_text) - .pipe_as_ref(serde_json::from_str::) - .expect("parse stdout as JsonData") - .body - .pipe(JsonTree::::try_from) - .expect("get tree of bytes") - .tree; - sort_reflection_by(&mut tree, |a, b| a.name.cmp(&b.name)); - tree.name = "(total)".to_string(); - tree.size = expected_size; - for child in &mut tree.children { - let name = match child.name.as_str() { - "sources" => "main/sources", - "internal-hardlinks" => "main/internal-hardlinks", - name => panic!("Unexpected name: {name:?}"), - }; - child.name = name.to_string(); - } - tree - }; - assert_eq!(actual_tree, &expected_tree); - - let actual_shared_details: Vec<_> = tree - .shared - .details - .as_ref() - .expect("get details") - .iter() - .cloned() - .collect(); - let expected_shared_details = [ - ReflectionEntry { - ino: file_inode("one-internal-hardlink.txt"), - size: file_size("one-internal-hardlink.txt"), - links: 1 + 1, - paths: shared_paths(&[ - "sources/one-internal-hardlink.txt", - "internal-hardlinks/link-0.txt", - ]), - }, - ReflectionEntry { - ino: file_inode("two-internal-hardlinks.txt"), - size: file_size("two-internal-hardlinks.txt"), - links: 1 + 2, - paths: shared_paths(&[ - "sources/two-internal-hardlinks.txt", - "internal-hardlinks/link-1a.txt", - "internal-hardlinks/link-1b.txt", - ]), - }, - ReflectionEntry { - ino: file_inode("one-external-hardlink.txt"), - size: file_size("one-external-hardlink.txt"), - links: 1 + 1, - paths: shared_paths(&["sources/one-external-hardlink.txt"]), - }, - ReflectionEntry { - ino: file_inode("one-internal-one-external-hardlinks.txt"), - size: file_size("one-internal-one-external-hardlinks.txt"), - links: 1 + 1 + 1, - paths: shared_paths(&[ - "sources/one-internal-one-external-hardlinks.txt", - "internal-hardlinks/link-3a.txt", - ]), - }, - ] - .into_sorted_by_key(|item| u64::from(item.ino)); - assert_eq!(actual_shared_details, expected_shared_details); - - let actual_shared_summary = tree.shared.summary; - let expected_shared_summary = Summary::default() - .with_inodes(0 + 1 + 1 + 1 + 1) - .with_exclusive_inodes(0 + 1 + 1 + 0 + 0) - .with_all_links(0 + 2 + 3 + 2 + 3) - .with_detected_links(0 + 2 + 3 + 1 + 2) - .with_exclusive_links(0 + 2 + 3 + 0 + 0) - .with_shared_size( - Bytes::new(0) - + file_size("one-internal-hardlink.txt") - + file_size("two-internal-hardlinks.txt") - + file_size("one-external-hardlink.txt") - + file_size("one-internal-one-external-hardlinks.txt"), - ) - .with_exclusive_shared_size( - Bytes::new(0) - + file_size("one-internal-hardlink.txt") - + file_size("two-internal-hardlinks.txt"), - ) - .pipe(Some); - assert_eq!(actual_shared_summary, expected_shared_summary); - - let visualization = Command::new(PDU) - .with_current_dir(&workspace) - .with_arg("--quantity=apparent-size") - .with_arg("--deduplicate-hardlinks") - .with_arg("main/sources") - .with_arg("main/internal-hardlinks") - .pipe(stdio) - .output() - .expect("spawn command") - .pipe(stdout_text); - eprintln!("STDOUT:\n{visualization}"); - let actual_hardlinks_summary = visualization - .lines() - .skip_while(|line| !line.starts_with("Hardlinks detected!")) - .join("\n"); - let expected_hardlinks_summary = { - use parallel_disk_usage::size::Size; - use std::fmt::Write; - let mut summary = String::new(); - writeln!( - summary, - "Hardlinks detected! Some files have links outside this tree", - ) - .unwrap(); - writeln!( - summary, - "* Number of shared inodes: {total} total, {exclusive} exclusive", - total = expected_shared_summary.unwrap().inodes, - exclusive = expected_shared_summary.unwrap().exclusive_inodes, - ) - .unwrap(); - writeln!( - summary, - "* Total number of links: {total} total, {detected} detected, {exclusive} exclusive", - total = expected_shared_summary.unwrap().all_links, - detected = expected_shared_summary.unwrap().detected_links, - exclusive = expected_shared_summary.unwrap().exclusive_links, - ) - .unwrap(); - writeln!( - summary, - "* Total shared size: {total} total, {exclusive} exclusive", - total = expected_shared_summary - .unwrap() - .shared_size - .display(BytesFormat::MetricUnits), - exclusive = expected_shared_summary - .unwrap() - .exclusive_shared_size - .display(BytesFormat::MetricUnits), - ) - .unwrap(); - summary - }; - assert_eq!( - actual_hardlinks_summary.trim_end(), - expected_hardlinks_summary.trim_end(), - ); + let stderr = String::from_utf8(output.stderr).expect("parse stderr as UTF-8"); + let stderr = stderr.trim_end(); + assert!(!output.status.success()); + assert_eq!(stderr, EXPECTED_ERROR); + assert_eq!(&output.stdout, &[] as &[u8]); } #[test] -fn multiple_hardlinks_to_a_single_file() { +fn three_args() { let links = 10; - let args = ["file.txt", "link.3", "link.5"]; let workspace = SampleWorkspace::multiple_hardlinks_to_a_single_file(100_000, links); - let tree = Command::new(PDU) + let output = Command::new(PDU) .with_current_dir(&workspace) .with_arg("--quantity=apparent-size") .with_arg("--deduplicate-hardlinks") - .with_arg("--json-output") - .with_args(args) + .with_arg("file.txt") + .with_arg("link.3") + .with_arg("link.5") .pipe(stdio) .output() - .expect("spawn command") - .pipe(stdout_text) - .pipe_as_ref(serde_json::from_str::) - .expect("parse stdout as JsonData") - .body - .pipe(JsonTree::::try_from) - .expect("get tree of bytes"); - - let file_size = workspace - .join("file.txt") - .pipe_as_ref(read_apparent_size) - .pipe(Bytes::new); - - let file_inode = workspace - .join("file.txt") - .pipe_as_ref(read_inode_number) - .pipe(InodeNumber::from); - - let actual_size = tree.size; - let expected_size = file_size; - assert_eq!(actual_size, expected_size); - - let actual_children = tree - .children - .clone() - .into_sorted_by(|a, b| a.name.cmp(&b.name)); - let expected_children = args.map(|name| Reflection { - name: name.to_string(), - size: file_size, - children: Vec::new(), - }); - assert_eq!(actual_children, expected_children); + .expect("spawn command"); - let actual_shared_details: Vec<_> = tree - .shared - .details - .as_ref() - .expect("get details") - .iter() - .cloned() - .collect(); - let expected_shared_details = [ReflectionEntry { - ino: file_inode, - size: file_size, - links: 1 + links, - paths: args - .map(PathBuf::from) - .pipe(HashSet::from) - .pipe(LinkPathListReflection), - }]; - assert_eq!(actual_shared_details, expected_shared_details); - - let actual_shared_summary = tree.shared.summary; - let expected_shared_summary = Summary::default() - .with_inodes(1) - .with_exclusive_inodes(0) - .with_all_links(1 + links) - .with_detected_links(args.len()) - .with_exclusive_links(0) - .with_shared_size(file_size) - .with_exclusive_shared_size(Bytes::new(0)) - .pipe(Some); - assert_eq!(actual_shared_summary, expected_shared_summary); - - let visualization = Command::new(PDU) - .with_current_dir(&workspace) - .with_arg("--quantity=apparent-size") - .with_arg("--deduplicate-hardlinks") - .with_args(args) - .pipe(stdio) - .output() - .expect("spawn command") - .pipe(stdout_text); - eprintln!("STDOUT:\n{visualization}"); - let actual_hardlinks_summary = visualization - .lines() - .skip_while(|line| !line.starts_with("Hardlinks detected!")) - .join("\n"); - let expected_hardlinks_summary = { - use parallel_disk_usage::size::Size; - use std::fmt::Write; - let mut summary = String::new(); - writeln!( - summary, - "Hardlinks detected! All hardlinks within this tree have links without", - ) - .unwrap(); - writeln!(summary, "* Number of shared inodes: 1").unwrap(); - writeln!( - summary, - "* Total number of links: {total} total, {detected} detected", - total = expected_shared_summary.unwrap().all_links, - detected = expected_shared_summary.unwrap().detected_links, - ) - .unwrap(); - writeln!( - summary, - "* Total shared size: {}", - file_size.display(BytesFormat::MetricUnits), - ) - .unwrap(); - summary - }; - assert_eq!( - actual_hardlinks_summary.trim_end(), - expected_hardlinks_summary.trim_end(), - ); -} - -#[test] -fn multiple_duplicated_arguments() { - #![expect(clippy::identity_op)] - - let sizes = [200_000, 220_000, 310_000, 110_000, 210_000]; - let workspace = SampleWorkspace::simple_tree_with_some_symlinks_and_hardlinks(sizes); - - let mut tree = Command::new(PDU) - .with_current_dir(&workspace) - .with_arg("--quantity=apparent-size") - .with_arg("--deduplicate-hardlinks") - .with_arg("--json-output") - .with_arg("main/sources") // expected to be kept - .with_arg("main/main-itself/sources") // expected to be removed - .with_arg("workspace-itself/main/parent-of-main/main-mirror/internal-hardlinks") // expected to be kept - .with_arg("main/internal-hardlinks") // expected to be removed - .pipe(stdio) - .output() - .expect("spawn command") - .pipe(stdout_text) - .pipe_as_ref(serde_json::from_str::) - .expect("parse stdout as JsonData") - .body - .pipe(JsonTree::::try_from) - .expect("get tree of bytes"); - sort_reflection_by(&mut tree, |a, b| a.name.cmp(&b.name)); - let tree = tree; - - let file_size = |name: &str| { - workspace - .join("main/sources") - .join(name) - .pipe_as_ref(read_apparent_size) - .pipe(Bytes::new) - }; - - let inode_size = |path: &str| { - workspace - .join(path) - .pipe_as_ref(read_apparent_size) - .pipe(Bytes::new) - }; - - let file_inode = |name: &str| { - workspace - .join("main/sources") - .join(name) - .pipe_as_ref(read_inode_number) - .pipe(InodeNumber::from) - }; - - let shared_paths = |suffices: &[&str]| { - suffices - .iter() - .map(PathBuf::from) - .collect::>() - .pipe(LinkPathListReflection) - }; - - let actual_size = tree.size; - let expected_size = Bytes::new(0) - + inode_size("main/sources") - + inode_size("main/internal-hardlinks") - + file_size("no-hardlinks.txt") - + file_size("one-internal-hardlink.txt") - + file_size("two-internal-hardlinks.txt") - + file_size("one-external-hardlink.txt") - + file_size("one-internal-one-external-hardlinks.txt"); - assert_eq!(actual_size, expected_size); - - let actual_tree = &tree.tree; - let expected_tree = { - let mut tree = Command::new(PDU) - .with_current_dir(&workspace) - .with_arg("--quantity=apparent-size") - .with_arg("--deduplicate-hardlinks") - .with_arg("--json-output") - .with_arg("main") - .pipe(stdio) - .output() - .expect("spawn command") - .pipe(stdout_text) - .pipe_as_ref(serde_json::from_str::) - .expect("parse stdout as JsonData") - .body - .pipe(JsonTree::::try_from) - .expect("get tree of bytes") - .tree; - tree.name = "(total)".to_string(); - tree.size = expected_size; - for child in &mut tree.children { - let name = match child.name.as_str() { - "sources" => "main/sources", - "internal-hardlinks" => { - "workspace-itself/main/parent-of-main/main-mirror/internal-hardlinks" - } - name => panic!("Unexpected name: {name:?}"), - }; - child.name = name.to_string(); - } - sort_reflection_by(&mut tree, |a, b| a.name.cmp(&b.name)); - tree - }; - assert_eq!(actual_tree, &expected_tree); - - let actual_shared_details: Vec<_> = tree - .shared - .details - .as_ref() - .expect("get details") - .iter() - .cloned() - .collect(); - let expected_shared_details = [ - ReflectionEntry { - ino: file_inode("one-internal-hardlink.txt"), - size: file_size("one-internal-hardlink.txt"), - links: 1 + 1, - paths: shared_paths(&[ - "main/sources/one-internal-hardlink.txt", - "workspace-itself/main/parent-of-main/main-mirror/internal-hardlinks/link-0.txt", - ]), - }, - ReflectionEntry { - ino: file_inode("two-internal-hardlinks.txt"), - size: file_size("two-internal-hardlinks.txt"), - links: 1 + 2, - paths: shared_paths(&[ - "main/sources/two-internal-hardlinks.txt", - "workspace-itself/main/parent-of-main/main-mirror/internal-hardlinks/link-1a.txt", - "workspace-itself/main/parent-of-main/main-mirror/internal-hardlinks/link-1b.txt", - ]), - }, - ReflectionEntry { - ino: file_inode("one-external-hardlink.txt"), - size: file_size("one-external-hardlink.txt"), - links: 1 + 1, - paths: shared_paths(&["main/sources/one-external-hardlink.txt"]), - }, - ReflectionEntry { - ino: file_inode("one-internal-one-external-hardlinks.txt"), - size: file_size("one-internal-one-external-hardlinks.txt"), - links: 1 + 1 + 1, - paths: shared_paths(&[ - "main/sources/one-internal-one-external-hardlinks.txt", - "workspace-itself/main/parent-of-main/main-mirror/internal-hardlinks/link-3a.txt", - ]), - }, - ] - .into_sorted_by_key(|item| u64::from(item.ino)); - assert_eq!(actual_shared_details, expected_shared_details); - - let actual_shared_summary = tree.shared.summary; - let expected_shared_summary = Summary::default() - .with_inodes(0 + 1 + 1 + 1 + 1) - .with_exclusive_inodes(0 + 1 + 1 + 0 + 0) - .with_all_links(0 + 2 + 3 + 2 + 3) - .with_detected_links(0 + 2 + 3 + 1 + 2) - .with_exclusive_links(0 + 2 + 3 + 0 + 0) - .with_shared_size( - Bytes::new(0) - + file_size("one-internal-hardlink.txt") - + file_size("two-internal-hardlinks.txt") - + file_size("one-external-hardlink.txt") - + file_size("one-internal-one-external-hardlinks.txt"), - ) - .with_exclusive_shared_size( - Bytes::new(0) - + file_size("one-internal-hardlink.txt") - + file_size("two-internal-hardlinks.txt"), - ) - .pipe(Some); - assert_eq!(actual_shared_summary, expected_shared_summary); - - let visualization = Command::new(PDU) - .with_current_dir(&workspace) - .with_arg("--quantity=apparent-size") - .with_arg("--deduplicate-hardlinks") - .with_arg("main/sources") - .with_arg("main/internal-hardlinks") - .pipe(stdio) - .output() - .expect("spawn command") - .pipe(stdout_text); - eprintln!("STDOUT:\n{visualization}"); - let actual_hardlinks_summary = visualization - .lines() - .skip_while(|line| !line.starts_with("Hardlinks detected!")) - .join("\n"); - let expected_hardlinks_summary = { - use parallel_disk_usage::size::Size; - use std::fmt::Write; - let mut summary = String::new(); - writeln!( - summary, - "Hardlinks detected! Some files have links outside this tree", - ) - .unwrap(); - writeln!( - summary, - "* Number of shared inodes: {total} total, {exclusive} exclusive", - total = expected_shared_summary.unwrap().inodes, - exclusive = expected_shared_summary.unwrap().exclusive_inodes, - ) - .unwrap(); - writeln!( - summary, - "* Total number of links: {total} total, {detected} detected, {exclusive} exclusive", - total = expected_shared_summary.unwrap().all_links, - detected = expected_shared_summary.unwrap().detected_links, - exclusive = expected_shared_summary.unwrap().exclusive_links, - ) - .unwrap(); - writeln!( - summary, - "* Total shared size: {total} total, {exclusive} exclusive", - total = expected_shared_summary - .unwrap() - .shared_size - .display(BytesFormat::MetricUnits), - exclusive = expected_shared_summary - .unwrap() - .exclusive_shared_size - .display(BytesFormat::MetricUnits), - ) - .unwrap(); - summary - }; - assert_eq!( - actual_hardlinks_summary.trim_end(), - expected_hardlinks_summary.trim_end(), - ); + let stderr = String::from_utf8(output.stderr).expect("parse stderr as UTF-8"); + let stderr = stderr.trim_end(); + assert!(!output.status.success()); + assert_eq!(stderr, EXPECTED_ERROR); + assert_eq!(&output.stdout, &[] as &[u8]); }