@@ -422,50 +422,11 @@ fn output_text(
422422 println ! ( ) ;
423423 }
424424
425- // Summary section
426- #[ allow(
427- clippy:: cast_precision_loss,
428- clippy:: cast_possible_truncation,
429- clippy:: cast_sign_loss
430- ) ]
431- let open_pct = if counts. total > 0 {
432- ( counts. open as f64 / counts. total as f64 * 100.0 ) as u32
433- } else {
434- 0
435- } ;
436-
437- #[ allow(
438- clippy:: cast_precision_loss,
439- clippy:: cast_possible_truncation,
440- clippy:: cast_sign_loss
441- ) ]
442- let done_pct = if counts. total > 0 {
443- ( counts. done as f64 / counts. total as f64 * 100.0 ) as u32
444- } else {
445- 0
446- } ;
447-
448- #[ allow(
449- clippy:: cast_precision_loss,
450- clippy:: cast_possible_truncation,
451- clippy:: cast_sign_loss
452- ) ]
453- let waived_pct = if counts. total > 0 {
454- ( counts. waived as f64 / counts. total as f64 * 100.0 ) as u32
455- } else {
456- 0
457- } ;
458-
459- #[ allow(
460- clippy:: cast_precision_loss,
461- clippy:: cast_possible_truncation,
462- clippy:: cast_sign_loss
463- ) ]
464- let blocked_pct = if counts. total > 0 {
465- ( counts. blocked as f64 / counts. total as f64 * 100.0 ) as u32
466- } else {
467- 0
468- } ;
425+ // Summary section - use largest-remainder rounding so percentages sum to 100%
426+ let [ open_pct, done_pct, waived_pct, blocked_pct] = round_percentages (
427+ counts. total ,
428+ [ counts. open , counts. done , counts. waived , counts. blocked ] ,
429+ ) ;
469430
470431 if let Some ( t) = theme {
471432 println ! ( "{}" , t. style_label( "Summary" ) ) ;
@@ -482,6 +443,42 @@ fn output_text(
482443 }
483444}
484445
446+ /// Round percentages using the largest-remainder method so they always sum to 100%.
447+ ///
448+ /// Given a total and an array of counts, returns an array of integer percentages
449+ /// that sum to exactly 100 (or all zeros if total is 0).
450+ #[ allow(
451+ clippy:: cast_precision_loss,
452+ clippy:: cast_possible_truncation,
453+ clippy:: cast_sign_loss
454+ ) ]
455+ fn round_percentages < const N : usize > ( total : usize , counts : [ usize ; N ] ) -> [ u32 ; N ] {
456+ if total == 0 {
457+ return [ 0 ; N ] ;
458+ }
459+
460+ let total_f = total as f64 ;
461+ let exact: Vec < f64 > = counts. iter ( ) . map ( |& c| c as f64 / total_f * 100.0 ) . collect ( ) ;
462+ let mut floored: Vec < u32 > = exact. iter ( ) . map ( |& v| v as u32 ) . collect ( ) ;
463+ let remainder: u32 = 100 - floored. iter ( ) . sum :: < u32 > ( ) ;
464+
465+ // Distribute remaining points to entries with largest fractional parts
466+ let mut indices: Vec < usize > = ( 0 ..N ) . collect ( ) ;
467+ indices. sort_by ( |& a, & b| {
468+ let frac_a = exact[ a] - f64:: from ( floored[ a] ) ;
469+ let frac_b = exact[ b] - f64:: from ( floored[ b] ) ;
470+ frac_b. partial_cmp ( & frac_a) . unwrap ( )
471+ } ) ;
472+
473+ for & i in indices. iter ( ) . take ( remainder as usize ) {
474+ floored[ i] += 1 ;
475+ }
476+
477+ let mut result = [ 0u32 ; N ] ;
478+ result. copy_from_slice ( & floored) ;
479+ result
480+ }
481+
485482/// Print a single task summary line
486483fn print_task_summary ( task : & TaskRecord , theme : Option < & CliTheme > ) {
487484 let checkbox = match task. status {
@@ -617,4 +614,41 @@ mod tests {
617614 assert_eq ! ( summary. waived, 15 ) ;
618615 assert_eq ! ( summary. blocked, 5 ) ;
619616 }
617+
618+ #[ test]
619+ fn test_round_percentages_exact ( ) {
620+ // 25+25+25+25 = 100, no rounding needed
621+ let result = round_percentages ( 100 , [ 25 , 25 , 25 , 25 ] ) ;
622+ assert_eq ! ( result, [ 25 , 25 , 25 , 25 ] ) ;
623+ assert_eq ! ( result. iter( ) . sum:: <u32 >( ) , 100 ) ;
624+ }
625+
626+ #[ test]
627+ fn test_round_percentages_zero_total ( ) {
628+ let result = round_percentages ( 0 , [ 0 , 0 , 0 , 0 ] ) ;
629+ assert_eq ! ( result, [ 0 , 0 , 0 , 0 ] ) ;
630+ }
631+
632+ #[ test]
633+ fn test_round_percentages_172_of_173 ( ) {
634+ // The reported bug: 172/173 = 99.42%, 1/173 = 0.58%
635+ // Should round to 99% + 1% = 100%, not 99% + 0% = 99%
636+ let result = round_percentages ( 173 , [ 0 , 172 , 1 , 0 ] ) ;
637+ assert_eq ! ( result, [ 0 , 99 , 1 , 0 ] ) ;
638+ assert_eq ! ( result. iter( ) . sum:: <u32 >( ) , 100 ) ;
639+ }
640+
641+ #[ test]
642+ fn test_round_percentages_thirds ( ) {
643+ // 1/3 each ≈ 33.33% — two get 33%, one gets 34%
644+ let result = round_percentages ( 3 , [ 1 , 1 , 1 ] ) ;
645+ assert_eq ! ( result. iter( ) . sum:: <u32 >( ) , 100 ) ;
646+ assert ! ( result. iter( ) . all( |& v| v == 33 || v == 34 ) ) ;
647+ }
648+
649+ #[ test]
650+ fn test_round_percentages_all_in_one ( ) {
651+ let result = round_percentages ( 50 , [ 50 , 0 , 0 , 0 ] ) ;
652+ assert_eq ! ( result, [ 100 , 0 , 0 , 0 ] ) ;
653+ }
620654}
0 commit comments