appmem 0.1.0

See which macOS app is using your memory, grouped per app, in the terminal
//! Plain-text report printed by `--once`.

use std::fmt::Write;

use crate::format::{format_bytes, truncate_to_width};
use crate::grouping::Group;
use crate::model::{Pressure, SystemStats};

pub const BAR_WIDTH: usize = 13;
/// Columns used by everything except the name: gaps, process count, total and bar.
const FIXED_COLUMNS: usize = 2 + 3 + 5 + 2 + 9 + 2 + BAR_WIDTH;

pub fn pressure_word(pressure: Pressure) -> &'static str {
    match pressure {
        Pressure::Normal => "normal",
        Pressure::Warn => "warn",
        Pressure::Critical => "critical",
    }
}

/// "6.90/8.00" style pair, both in GB with two decimals.
pub fn gb(bytes: u64) -> String {
    format!("{:.2}", bytes as f64 / (1u64 << 30) as f64)
}

/// `█`/`░` bar of `width` cells for `value` out of `max`.
pub fn bar(value: u64, max: u64, width: usize) -> String {
    let filled = if max == 0 {
        0
    } else {
        ((value as f64 / max as f64) * width as f64).round() as usize
    };
    let filled = filled.min(width);
    format!("{}{}", "█".repeat(filled), "░".repeat(width - filled))
}

/// Total with a `~` prefix when it includes an estimate.
pub fn total_label(group: &Group) -> String {
    let prefix = if group.estimated { "~" } else { "" };
    format!("{prefix}{}", format_bytes(group.total))
}

pub fn render_report(stats: &SystemStats, groups: &[Group], width: usize) -> String {
    let mut out = format!(
        "Memory {}/{} GB (wired {})  Pressure {}  Swap {} GB\n",
        gb(stats.used),
        gb(stats.total),
        gb(stats.wired),
        pressure_word(stats.pressure),
        gb(stats.swap_used)
    );
    let name_width = width.saturating_sub(FIXED_COLUMNS).max(8);
    for group in groups {
        let name = truncate_to_width(&group.name, name_width);
        let pad = name_width - unicode_width::UnicodeWidthStr::width(name.as_str());
        let _ = writeln!(
            out,
            "{name}{}  {:>3} proc  {:>9}  {}",
            " ".repeat(pad),
            group.procs.len(),
            total_label(group),
            bar(group.total, stats.total, BAR_WIDTH)
        );
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::attribution::Bucket;
    use crate::model::{Pressure, SystemStats};

    const GB: u64 = 1024 * 1024 * 1024;

    fn group(key: Bucket, name: &str, total: u64, estimated: bool) -> Group {
        Group {
            key,
            name: name.into(),
            bundle_id: None,
            procs: vec![],
            total,
            estimated,
        }
    }

    #[test]
    fn report_lists_system_last_with_tilde() {
        let stats = SystemStats {
            total: 8 * GB,
            used: 6 * GB,
            wired: GB,
            swap_used: 2 * GB,
            app_footprint: 0,
            pressure: Pressure::Warn,
        };
        let groups = vec![
            group(
                Bucket::App("/Applications/Google Chrome.app".into()),
                "Google Chrome",
                3 * GB,
                false,
            ),
            group(Bucket::System, "System", GB, true),
        ];
        let out = render_report(&stats, &groups, 80);
        let lines: Vec<&str> = out.lines().collect();
        assert_eq!(
            lines[0],
            "Memory 6.00/8.00 GB (wired 1.00)  Pressure warn  Swap 2.00 GB"
        );
        assert!(lines[1].starts_with("Google Chrome"));
        assert!(lines.last().unwrap().starts_with("System"));
        assert!(lines.last().unwrap().contains("~1.00 GB"));
        assert!(out.lines().all(|l| unicode_width::UnicodeWidthStr::width(l) <= 80));
    }
}