From 51e7431e63afafea153f26e27a7749439c711536 Mon Sep 17 00:00:00 2001 From: vhmns Date: Mon, 7 Sep 2026 14:37:19 +0700 Subject: [PATCH] fix(cli): handle singular counts in scan summary Add a plural helper for English pluralization and use it for findings, duplicate sets count in the scan summary, and the duplicate files header. Fixes #99 --- crates/diskern-cli/src/main.rs | 30 ++++++++-- crates/diskern-cli/tests/singular_summary.rs | 59 ++++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 crates/diskern-cli/tests/singular_summary.rs diff --git a/crates/diskern-cli/src/main.rs b/crates/diskern-cli/src/main.rs index b2474c2..cd4a24f 100644 --- a/crates/diskern-cli/src/main.rs +++ b/crates/diskern-cli/src/main.rs @@ -76,6 +76,15 @@ fn human_bytes(n: u64) -> String { } } +/// Suffix for English pluralization: empty for 1, "s" for any other count. +fn plural(n: usize) -> &'static str { + if n == 1 { + "" + } else { + "s" + } +} + /// Display strings live in the CLI, not in diskern-core — the engine is /// deliberately UI-agnostic. These mirror the labels the desktop app uses /// so the two front ends describe the same finding the same way. @@ -146,7 +155,7 @@ fn print_findings(findings: &[&Finding], top: usize) { "{} — {} finding{} · {}", verdict_label(verdict), group.len(), - if group.len() == 1 { "" } else { "s" }, + plural(group.len()), human_bytes(total) ); @@ -239,10 +248,12 @@ fn main() -> Result<()> { println!("Rules: external database — {}", path.display()); } println!( - "Reclaimable: {} across {} findings and {} duplicate sets.", + "Reclaimable: {} across {} finding{} and {} duplicate set{}.", human_bytes(report.total_reclaimable), report.findings.len(), - report.duplicate_sets.len() + plural(report.findings.len()), + report.duplicate_sets.len(), + plural(report.duplicate_sets.len()) ); // Filter after the summary line, so the headline totals // still describe the whole scan rather than the slice. @@ -260,8 +271,9 @@ fn main() -> Result<()> { let wasted: u64 = report.duplicate_sets.iter().map(|d| d.wasted).sum(); println!(); println!( - "Duplicate files — {} sets · {} wasted", + "Duplicate files — {} set{} · {} wasted", report.duplicate_sets.len(), + plural(report.duplicate_sets.len()), human_bytes(wasted) ); let dup_shown = if top == 0 { @@ -293,7 +305,15 @@ fn main() -> Result<()> { #[cfg(test)] mod tests { - use super::human_bytes; + use super::{human_bytes, plural}; + + #[test] + fn plural_returns_empty_only_for_singular() { + assert_eq!(plural(0), "s"); + assert_eq!(plural(1), ""); + assert_eq!(plural(2), "s"); + assert_eq!(plural(10), "s"); + } #[test] fn scales_to_a_readable_unit() { diff --git a/crates/diskern-cli/tests/singular_summary.rs b/crates/diskern-cli/tests/singular_summary.rs new file mode 100644 index 0000000..b406779 --- /dev/null +++ b/crates/diskern-cli/tests/singular_summary.rs @@ -0,0 +1,59 @@ +use std::fs; +use std::process::Command; +use tempfile::tempdir; + +fn run_scan(root: &std::path::Path) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_diskern")); + command.arg("scan").arg(root).arg("--top").arg("0"); + command.output().expect("diskern should start") +} + +#[test] +fn scan_with_single_duplicate_set_prints_singular_summary() { + let root = tempdir().unwrap(); + // Two identical files with non-zero size form exactly 1 duplicate set. + fs::write(root.path().join("first.txt"), b"duplicate payload here").unwrap(); + fs::write(root.path().join("second.txt"), b"duplicate payload here").unwrap(); + + let output = run_scan(root.path()); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("1 duplicate set."), + "expected '1 duplicate set.', got: {stdout}" + ); + assert!( + stdout.contains("Duplicate files — 1 set ·"), + "expected 'Duplicate files — 1 set ·', got: {stdout}" + ); +} + +#[test] +fn scan_with_single_finding_prints_singular_summary() { + let root = tempdir().unwrap(); + let cache = root.path().join(".cache/google-chrome/Default/Cache"); + fs::create_dir_all(&cache).unwrap(); + fs::write(cache.join("entry"), b"cached-single-entry").unwrap(); + + let output = run_scan(root.path()); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("across 1 finding and 0 duplicate sets."), + "expected 'across 1 finding and 0 duplicate sets.', got: {stdout}" + ); + assert!( + stdout.contains("1 finding ·"), + "expected '1 finding ·', got: {stdout}" + ); +}