@@ -675,3 +675,168 @@ fn exclusive_hardlinks_only() {
675675 expected_hardlinks_summary. trim_end( ) ,
676676 ) ;
677677}
678+
679+ #[ test]
680+ fn exclusive_only_and_external_only_hardlinks ( ) {
681+ let files_per_branch = 2 * 4 ;
682+ let workspace =
683+ SampleWorkspace :: complex_tree_with_shared_and_unique_files ( files_per_branch, 100_000 ) ;
684+
685+ let tree = Command :: new ( PDU )
686+ . with_current_dir ( & workspace)
687+ . with_arg ( "--min-ratio=0" )
688+ . with_arg ( "--quantity=apparent-size" )
689+ . with_arg ( "--json-output" )
690+ . with_arg ( "--deduplicate-hardlinks" )
691+ . with_arg ( "only-hardlinks/mixed" )
692+ . pipe ( stdio)
693+ . output ( )
694+ . expect ( "spawn command" )
695+ . pipe ( stdout_text)
696+ . pipe_as_ref ( serde_json:: from_str :: < JsonData > )
697+ . expect ( "parse stdout as JsonData" )
698+ . body
699+ . pipe ( JsonTree :: < Bytes > :: try_from)
700+ . expect ( "get tree of bytes" ) ;
701+
702+ let file_size = workspace
703+ . join ( "only-hardlinks/mixed/link0-0.txt" )
704+ . pipe_as_ref ( read_apparent_size)
705+ . pipe ( Bytes :: new) ;
706+
707+ let inode_size = |path : & str | {
708+ workspace
709+ . join ( path)
710+ . pipe_as_ref ( read_apparent_size)
711+ . pipe ( Bytes :: new)
712+ } ;
713+
714+ let file_inode = |name : & str | {
715+ workspace
716+ . join ( "only-hardlinks/mixed" )
717+ . join ( name)
718+ . pipe_as_ref ( read_inode_number)
719+ . pipe ( InodeNumber :: from)
720+ } ;
721+
722+ let shared_paths = |file_names : & [ & str ] | {
723+ file_names
724+ . iter ( )
725+ . map ( |file_name| PathBuf :: from ( "only-hardlinks/mixed" ) . join ( file_name) )
726+ . collect :: < HashSet < _ > > ( )
727+ . pipe ( LinkPathListReflection )
728+ } ;
729+
730+ let actual_size = tree. size ;
731+ let expected_size = inode_size ( "only-hardlinks/mixed" ) + file_size * files_per_branch;
732+ assert_eq ! ( actual_size, expected_size) ;
733+
734+ let actual_shared_details: Vec < _ > = tree
735+ . shared
736+ . details
737+ . as_ref ( )
738+ . unwrap ( )
739+ . iter ( )
740+ . cloned ( )
741+ . collect ( ) ;
742+ let expected_shared_details = iter:: empty ( )
743+ . par_bridge ( )
744+ . chain (
745+ ( 0 ..( files_per_branch / 2 ) )
746+ . par_bridge ( )
747+ . map ( |index| ReflectionEntry {
748+ ino : file_inode ( & format ! ( "link0-{index}.txt" ) ) ,
749+ size : file_size,
750+ links : 2 ,
751+ paths : shared_paths ( & [ & format ! ( "link0-{index}.txt" ) ] ) ,
752+ } ) ,
753+ )
754+ . chain (
755+ ( ( files_per_branch / 2 ) ..files_per_branch)
756+ . par_bridge ( )
757+ . map ( |index| ReflectionEntry {
758+ ino : file_inode ( & format ! ( "link0-{index}.txt" ) ) ,
759+ size : file_size,
760+ links : 2 ,
761+ paths : shared_paths ( & [
762+ & format ! ( "link0-{index}.txt" ) ,
763+ & format ! ( "link1-{index}.txt" ) ,
764+ ] ) ,
765+ } ) ,
766+ )
767+ . collect :: < Vec < _ > > ( )
768+ . into_sorted_by_key ( |item : & ReflectionEntry < Bytes > | u64:: from ( item. ino ) ) ;
769+ assert_eq ! ( actual_shared_details, expected_shared_details) ;
770+
771+ let actual_shared_summary = tree. shared . summary ;
772+ let expected_shared_summary = Summary :: default ( )
773+ . with_inodes ( files_per_branch)
774+ . with_exclusive_inodes ( files_per_branch / 2 )
775+ . with_all_links ( 2 * files_per_branch as u64 )
776+ . with_detected_links ( files_per_branch + files_per_branch / 2 )
777+ . with_exclusive_links ( files_per_branch * 2 / 2 )
778+ . with_shared_size ( files_per_branch * file_size)
779+ . with_exclusive_shared_size ( ( files_per_branch / 2 ) * file_size)
780+ . pipe ( Some ) ;
781+ assert_eq ! ( actual_shared_summary, expected_shared_summary) ;
782+
783+ let visualization = Command :: new ( PDU )
784+ . with_current_dir ( & workspace)
785+ . with_arg ( "--quantity=apparent-size" )
786+ . with_arg ( "--deduplicate-hardlinks" )
787+ . with_arg ( "only-hardlinks/mixed" )
788+ . pipe ( stdio)
789+ . output ( )
790+ . expect ( "spawn command" )
791+ . pipe ( stdout_text) ;
792+
793+ eprintln ! ( "STDOUT:\n {visualization}" ) ;
794+
795+ let actual_hardlinks_summary = visualization
796+ . lines ( )
797+ . skip_while ( |line| !line. starts_with ( "Hardlinks detected!" ) )
798+ . join ( "\n " ) ;
799+ let expected_hardlinks_summary = {
800+ use parallel_disk_usage:: size:: Size ;
801+ use std:: fmt:: Write ;
802+ let mut summary = String :: new ( ) ;
803+ writeln ! (
804+ summary,
805+ "Hardlinks detected! Some files have links outside this tree" ,
806+ )
807+ . unwrap ( ) ;
808+ writeln ! (
809+ summary,
810+ "* Number of shared inodes: {total} total, {exclusive} exclusive" ,
811+ total = expected_shared_summary. unwrap( ) . inodes,
812+ exclusive = expected_shared_summary. unwrap( ) . exclusive_inodes,
813+ )
814+ . unwrap ( ) ;
815+ writeln ! (
816+ summary,
817+ "* Total number of links: {total} total, {detected} detected, {exclusive} exclusive" ,
818+ total = expected_shared_summary. unwrap( ) . all_links,
819+ detected = expected_shared_summary. unwrap( ) . detected_links,
820+ exclusive = expected_shared_summary. unwrap( ) . exclusive_links,
821+ )
822+ . unwrap ( ) ;
823+ writeln ! (
824+ summary,
825+ "* Total shared size: {total} total, {exclusive} exclusive" ,
826+ total = expected_shared_summary
827+ . unwrap( )
828+ . shared_size
829+ . display( BytesFormat :: MetricUnits ) ,
830+ exclusive = expected_shared_summary
831+ . unwrap( )
832+ . exclusive_shared_size
833+ . display( BytesFormat :: MetricUnits ) ,
834+ )
835+ . unwrap ( ) ;
836+ summary
837+ } ;
838+ assert_eq ! (
839+ actual_hardlinks_summary. trim_end( ) ,
840+ expected_hardlinks_summary. trim_end( ) ,
841+ ) ;
842+ }
0 commit comments