From 95dc9f254273c10e9e030441e26f70575eb1ad6a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:29:35 +0000 Subject: [PATCH] Add aggregated TOTALS row to CLI recap summary - Updated `RecapStats` in `src/cli/output.rs` to include methods for calculating total counts across all hosts for each status type (ok, changed, failed, etc.). - Enhanced `recap` output to display a visually distinct "TOTALS" row at the bottom when multiple hosts are present. - Improved readability by bolding non-zero values in the stats columns. - Added unit test `test_recap_stats_totals` to verify total calculation logic. - Updated `.Jules/palette.md` with UX learnings about aggregated summaries. Co-authored-by: dolagoartur <146357947+dolagoartur@users.noreply.github.com> --- .Jules/palette.md | 4 ++ src/cli/output.rs | 147 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 142 insertions(+), 9 deletions(-) diff --git a/.Jules/palette.md b/.Jules/palette.md index c3c9ca50..e681a081 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -52,3 +52,7 @@ ## 2026-07-28 - [Box Drawing for Modern Aesthetics] **Learning:** Legacy CLI tools often use ASCII characters (like `*`) for separators, which can feel dated. Replacing them with Unicode box-drawing characters (like `─`) creates a cleaner, more continuous visual flow that matches modern design sensibilities without sacrificing terminal compatibility. **Action:** Replace repeated ASCII separator characters with their Unicode box-drawing equivalents (`─`, `━`, `═`) in headers and banners to improve visual polish. + +## 2024-05-27 - [Aggregated Summaries in CLI Output] +**Learning:** In multi-host CLI executions, individual host results can scroll off-screen, making it difficult to assess overall success/failure at a glance. Adding a distinct "TOTALS" row at the bottom provides immediate, high-level feedback that is essential for larger deployments. +**Action:** For commands that execute across multiple items/hosts, calculate and display an aggregated summary row (e.g., "TOTALS") aligned with the detail columns to allow quick validation of the entire operation. diff --git a/src/cli/output.rs b/src/cli/output.rs index fbdc4dc3..591d3161 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -351,6 +351,15 @@ impl OutputFormatter { let mut sorted_hosts: Vec<_> = stats.hosts.keys().collect(); sorted_hosts.sort(); + // Helper to format stats: dim if zero, colored and bold if non-zero + let fmt_stat = |label: &str, value: u32, color: colored::Color| -> String { + if value > 0 { + format!("{}={:<4}", label.color(color).bold(), value) + } else { + format!("{}={:<4}", label, value).dimmed().to_string() + } + }; + for host in sorted_hosts { let host_stats = &stats.hosts[host]; @@ -363,15 +372,6 @@ impl OutputFormatter { host.green() }; - // Helper to format stats: dim if zero, colored if non-zero - let fmt_stat = |label: &str, value: u32, color: colored::Color| -> String { - if value > 0 { - format!("{}={:<4}", label.color(color), value) - } else { - format!("{}={:<4}", label, value).dimmed().to_string() - } - }; - // Manual padding to ensure proper visual alignment with ANSI codes let padding_len = max_host_len.saturating_sub(measure_text_width(host)); print!("{}{:width$}: ", host_colored, "", width = padding_len); @@ -418,6 +418,78 @@ impl OutputFormatter { } } + // Print totals row if multiple hosts + if stats.hosts.len() > 1 { + if self.use_color { + // Print separator + // Visual width of stats part is roughly: + // ok=4 (7) + changed=4 (12) + unreachable=4 (16) + failed=4 (11) + skipped=4 (12) + rescued=4 (12) + ignored=4 (12) + spaces (6) + // Total stats width approx 88 chars + let width = max_host_len + 90; + println!("{}", "─".repeat(width).bright_black()); + + let label = "TOTALS"; + let padding_len = max_host_len.saturating_sub(measure_text_width(label)); + + print!( + "{}{:width$}: ", + label.bright_white().bold(), + "", + width = padding_len + ); + print!("{} ", fmt_stat("ok", stats.total_ok(), colored::Color::Green)); + print!( + "{} ", + fmt_stat("changed", stats.total_changed(), colored::Color::Yellow) + ); + print!( + "{} ", + fmt_stat( + "unreachable", + stats.total_unreachable(), + colored::Color::Red + ) + ); + print!( + "{} ", + fmt_stat( + "failed", + stats.total_failed_tasks(), + colored::Color::Red + ) + ); + print!( + "{} ", + fmt_stat("skipped", stats.total_skipped(), colored::Color::Cyan) + ); + print!( + "{} ", + fmt_stat("rescued", stats.total_rescued(), colored::Color::Magenta) + ); + print!( + "{} ", + fmt_stat("ignored", stats.total_ignored(), colored::Color::Blue) + ); + println!(); + } else { + let width = max_host_len + 90; + println!("{}", "-".repeat(width)); + let line = format!( + "{: u32 { + self.hosts.values().map(|h| h.ok).sum() + } + /// Get total changed count pub fn total_changed(&self) -> u32 { self.hosts.values().map(|h| h.changed).sum() @@ -1133,6 +1210,31 @@ impl RecapStats { pub fn total_failed(&self) -> u32 { self.hosts.values().map(|h| h.failed + h.unreachable).sum() } + + /// Get total failed tasks count (failed only) + pub fn total_failed_tasks(&self) -> u32 { + self.hosts.values().map(|h| h.failed).sum() + } + + /// Get total unreachable count + pub fn total_unreachable(&self) -> u32 { + self.hosts.values().map(|h| h.unreachable).sum() + } + + /// Get total skipped count + pub fn total_skipped(&self) -> u32 { + self.hosts.values().map(|h| h.skipped).sum() + } + + /// Get total rescued count + pub fn total_rescued(&self) -> u32 { + self.hosts.values().map(|h| h.rescued).sum() + } + + /// Get total ignored count + pub fn total_ignored(&self) -> u32 { + self.hosts.values().map(|h| h.ignored).sum() + } } /// Format a duration as a human-readable string @@ -1338,4 +1440,31 @@ mod tests { formatter.plan_field_change("test", Some("old"), Some("new"), false); formatter.plan_note("Note"); } + + #[test] + fn test_recap_stats_totals() { + let mut recap = RecapStats::new(); + // host1: 1 ok, 1 changed + recap.record("host1", TaskStatus::Ok); + recap.record("host1", TaskStatus::Changed); + + // host2: 1 failed, 1 skipped + recap.record("host2", TaskStatus::Failed); + recap.record("host2", TaskStatus::Skipped); + + // host3: 1 unreachable, 1 rescued, 1 ignored + recap.record("host3", TaskStatus::Unreachable); + recap.record("host3", TaskStatus::Rescued); + recap.record("host3", TaskStatus::Ignored); + + assert_eq!(recap.total_ok(), 1); + assert_eq!(recap.total_changed(), 1); + assert_eq!(recap.total_failed_tasks(), 1); + assert_eq!(recap.total_unreachable(), 1); + assert_eq!(recap.total_skipped(), 1); + assert_eq!(recap.total_rescued(), 1); + assert_eq!(recap.total_ignored(), 1); + + assert_eq!(recap.total_failed(), 2); // failed + unreachable + } }