diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f484fd9..b6ee20c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: # code but applies no lints to it, so without this the suite — by now # a good half of the crate — drifts unlinted while CI stays green. - run: cargo fmt --check - - run: cargo clippy -p diskern-core -p diskern-cli --all-targets -- -D warnings + - run: cargo clippy -p diskern-core -p diskern-cli --all-targets --all-features -- -D warnings # The engine's behaviour is platform-specific in ways a run on one OS # cannot reach, and it ships on more than one: `default_excludes` has a @@ -73,7 +73,7 @@ jobs: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: swatinem/rust-cache@v2 - - run: cargo test -p diskern-core -p diskern-cli + - run: cargo test -p diskern-core -p diskern-cli --all-features # Type-checks app/src-tauri against the current diskern-core. Until this # existed nothing compiled the Tauri crate between releases: a breaking diff --git a/crates/diskern-cli/src/main.rs b/crates/diskern-cli/src/main.rs index cd4a24f..0dfa439 100644 --- a/crates/diskern-cli/src/main.rs +++ b/crates/diskern-cli/src/main.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand, ValueEnum}; -use diskern_core::{report, rules::RulesDb, scanner, Category, Finding, Verdict}; +use diskern_core::{human_bytes, report, rules::RulesDb, scanner, Category, Finding, Verdict}; use std::path::PathBuf; use std::sync::Arc; @@ -56,26 +56,6 @@ impl From for Verdict { } } -/// Bytes at the largest unit that keeps the number short. Decimal units, -/// matching what disk vendors and the rest of the UI report. -fn human_bytes(n: u64) -> String { - const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; - let mut value = n as f64; - let mut unit = 0; - // 999.95, not 1000.0: at one decimal place anything at or above that - // rounds to "1000.0", which belongs in the next unit up. Choosing the - // unit before rounding printed 999_999 as "1000.0 KB". - while value >= 999.95 && unit < UNITS.len() - 1 { - value /= 1000.0; - unit += 1; - } - if unit == 0 { - format!("{n} B") - } else { - format!("{value:.1} {}", UNITS[unit]) - } -} - /// Suffix for English pluralization: empty for 1, "s" for any other count. fn plural(n: usize) -> &'static str { if n == 1 { diff --git a/crates/diskern-core/src/ai.rs b/crates/diskern-core/src/ai.rs index 162f1eb..6eb4910 100644 --- a/crates/diskern-core/src/ai.rs +++ b/crates/diskern-core/src/ai.rs @@ -8,7 +8,7 @@ //! - `AnthropicProvider` / `OpenAiProvider`: user pastes their own API key. //! - `OllamaProvider`: local models via http://localhost:11434. -use crate::Finding; +use crate::{human_bytes, Finding}; pub trait AiProvider: Send + Sync { /// Turn a set of findings into a short, plain-language explanation. @@ -32,9 +32,9 @@ impl AiProvider for TemplateNarrator { fn narrate(&self, findings: &[Finding]) -> Result { let total: u64 = findings.iter().map(|f| f.reclaimable).sum(); Ok(format!( - "Found {} items totalling {:.1} GB reclaimable. Top reasons: {}", + "Found {} items totalling {} reclaimable. Top reasons: {}", findings.len(), - total as f64 / 1e9, + human_bytes(total), findings .iter() .take(3) @@ -44,3 +44,56 @@ impl AiProvider for TemplateNarrator { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Category, FileEntry, Verdict}; + use std::path::PathBuf; + + fn sample_finding(path: &str, reclaimable: u64, reason: &str) -> Finding { + Finding { + entry: FileEntry { + path: PathBuf::from(path), + size: reclaimable, + modified: None, + accessed: None, + is_symlink: false, + hash: None, + }, + category: Category::BrowserCache, + verdict: Verdict::Safe, + risk_score: 0.1, + reasons: vec![reason.to_string()], + reclaimable, + } + } + + #[test] + fn template_narrator_narrates_facts_and_formats_bytes() { + let findings = vec![ + sample_finding("/cache/a", 50_000_000, "chrome cache expired"), + sample_finding("/cache/b", 30_000_000, "firefox cache expired"), + sample_finding("/cache/c", 20_000_000, "safari cache expired"), + sample_finding("/cache/d", 10_000_000, "edge cache expired"), + ]; + + let narrator = TemplateNarrator; + let narrative = narrator.narrate(&findings).unwrap(); + + assert_eq!( + narrative, + "Found 4 items totalling 110.0 MB reclaimable. Top reasons: chrome cache expired; firefox cache expired; safari cache expired" + ); + } + + #[test] + fn template_narrator_empty_findings() { + let narrator = TemplateNarrator; + let narrative = narrator.narrate(&[]).unwrap(); + assert_eq!( + narrative, + "Found 0 items totalling 0 B reclaimable. Top reasons: " + ); + } +} diff --git a/crates/diskern-core/src/lib.rs b/crates/diskern-core/src/lib.rs index 3f105b0..9191fd0 100644 --- a/crates/diskern-core/src/lib.rs +++ b/crates/diskern-core/src/lib.rs @@ -116,3 +116,40 @@ pub enum GenomeError { } pub type Result = std::result::Result; + +/// Format a byte count into a human-readable decimal string (e.g. "1.5 GB", "420.0 MB"), +/// matching what disk vendors and the rest of the UI report. +pub fn human_bytes(n: u64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut value = n as f64; + let mut unit = 0; + // 999.95, not 1000.0: at one decimal place anything at or above that + // rounds to "1000.0", which belongs in the next unit up. Choosing the + // unit before rounding printed 999_999 as "1000.0 KB". + while value >= 999.95 && unit < UNITS.len() - 1 { + value /= 1000.0; + unit += 1; + } + if unit == 0 { + format!("{n} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_bytes_formatting() { + assert_eq!(human_bytes(0), "0 B"); + assert_eq!(human_bytes(500), "500 B"); + assert_eq!(human_bytes(999), "999 B"); + assert_eq!(human_bytes(1_000), "1.0 KB"); + assert_eq!(human_bytes(999_950), "1.0 MB"); + assert_eq!(human_bytes(10_000_000), "10.0 MB"); + assert_eq!(human_bytes(1_500_000_000), "1.5 GB"); + assert_eq!(human_bytes(2_000_000_000_000), "2.0 TB"); + } +}