rcpufetch 0.0.5

[ALPHA] A rusty crossplatform, but simple CLI binutil for reading CPU information.
//! Unified CPU report model + printer. Every OS module gathers raw data however it needs
//! to (procfs, sysctl, registry, ...) and converts it into a `CpuReport`; this module is
//! the single place that knows how to lay it out next to the logo and apply
//! `--style`/`--color`, replacing what used to be duplicated per-OS display code.

use crate::art::logos;
use crate::cla::{Args, Style};
use crate::style;

#[derive(Debug, Default, Clone)]
pub struct CpuReport {
    pub model: String,
    pub vendor: String,
    pub uarch: Option<String>,
    pub soc: Option<String>,
    pub technology_nm: Option<u32>,
    pub architecture: String,
    pub physical_cores: u32,
    pub logical_cores: u32,
    pub max_ghz: Option<f32>,
    pub base_ghz: Option<f32>,
    /// (per-core or single value, total) in KB
    pub l1i_kb: Option<(u32, u32)>,
    pub l1d_kb: Option<(u32, u32)>,
    pub l2_kb: Option<(u32, u32)>,
    pub l3_kb: Option<(u32, u32)>,
    pub flags: Vec<String>,
}

fn format_cache(size_kb: u32) -> String {
    if size_kb >= 1000 {
        format!("{:.1} MiB", size_kb as f32 / 1024.0)
    } else {
        format!("{} KiB", size_kb)
    }
}

/// `-F`/`--full-cpu-name` shows the raw brand string; otherwise strip the marketing
/// cruft upstream cpufetch's default view also drops: registered/trademark marks and
/// a trailing "@ x.xxGHz" clock-speed suffix (already shown separately as Max Frequency).
fn abbreviate_name(model: &str) -> String {
    let without_marks = model
        .replace("(R)", "")
        .replace("(TM)", "")
        .replace("(tm)", "");
    let without_freq = match without_marks.rfind('@') {
        Some(at) => without_marks[..at].to_string(),
        None => without_marks,
    };
    without_freq
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

fn info_lines(r: &CpuReport, full_name: bool) -> Vec<(String, String)> {
    let mut lines = Vec::new();
    let name = if full_name {
        r.model.clone()
    } else {
        abbreviate_name(&r.model)
    };
    lines.push(("Name".to_string(), name));
    lines.push(("Vendor".to_string(), r.vendor.clone()));
    if let Some(soc) = &r.soc {
        lines.push(("SoC".to_string(), soc.clone()));
    }
    if let Some(uarch) = &r.uarch {
        lines.push(("Microarchitecture".to_string(), uarch.clone()));
    }
    if let Some(nm) = r.technology_nm {
        lines.push(("Technology".to_string(), format!("{}nm", nm)));
    }
    lines.push((
        "Cores".to_string(),
        format!("{} physical, {} logical", r.physical_cores, r.logical_cores),
    ));
    if let Some(ghz) = r.max_ghz {
        lines.push(("Max Frequency".to_string(), format!("{:.2} GHz", ghz)));
    }
    if let Some(ghz) = r.base_ghz {
        lines.push(("Base Frequency".to_string(), format!("{:.2} GHz", ghz)));
    }
    if let Some((_, total)) = r.l1i_kb {
        lines.push(("L1i Size".to_string(), format_cache(total)));
    }
    if let Some((_, total)) = r.l1d_kb {
        lines.push(("L1d Size".to_string(), format_cache(total)));
    }
    if let Some((_, total)) = r.l2_kb {
        lines.push(("L2 Size".to_string(), format_cache(total)));
    }
    if let Some((_, total)) = r.l3_kb {
        lines.push(("L3 Size".to_string(), format_cache(total)));
    }
    if !r.flags.is_empty() {
        lines.push(("Flags".to_string(), r.flags.join(" ")));
    }
    lines
}

/// Terminal width in columns, from the `COLUMNS` env var if set, else a sane default.
fn terminal_width() -> usize {
    std::env::var("COLUMNS")
        .ok()
        .and_then(|c| c.parse().ok())
        .unwrap_or(80)
}

/// Greedily word-wrap `value` so no line exceeds `avail` columns (0 = no wrapping).
fn wrap_value(value: &str, avail: usize) -> Vec<String> {
    if avail == 0 || value.len() <= avail {
        return vec![value.to_string()];
    }
    let mut lines = Vec::new();
    let mut current = String::new();
    for word in value.split_whitespace() {
        if current.is_empty() {
            current.push_str(word);
        } else if current.len() + 1 + word.len() <= avail {
            current.push(' ');
            current.push_str(word);
        } else {
            lines.push(std::mem::take(&mut current));
            current = word.to_string();
        }
    }
    if !current.is_empty() {
        lines.push(current);
    }
    lines
}

fn logo_vendor_id(r: &CpuReport, logo_override: Option<&str>) -> String {
    if let Some(v) = logo_override {
        return v.to_string();
    }
    match r.vendor.as_str() {
        v if v.eq_ignore_ascii_case("Intel") => "GenuineIntel".to_string(),
        v if v.eq_ignore_ascii_case("AMD") => "AuthenticAMD".to_string(),
        v if v.eq_ignore_ascii_case("Apple") => "Apple".to_string(),
        v if v.eq_ignore_ascii_case("IBM") => "PowerPC".to_string(),
        _ if r.architecture.contains("arm") || r.architecture.contains("aarch64") => {
            "ARM".to_string()
        }
        other => other.to_string(),
    }
}

pub fn print(r: &CpuReport, args: &Args, logo_override: Option<&str>) {
    let (style, colors, colored) =
        match style::resolve(args.style, args.color.as_deref(), &r.vendor) {
            Ok(v) => v,
            Err(e) => {
                eprintln!("{}", e);
                std::process::exit(1);
            }
        };

    let lines = info_lines(r, args.full_cpu_name);
    let label_color = if colored {
        colors.label.fg()
    } else {
        String::new()
    };
    let value_color = if colored {
        colors.value.fg()
    } else {
        String::new()
    };
    let reset = if colored { style::RESET } else { "" };

    let vendor_id = logo_vendor_id(r, logo_override);
    let ansi_logo_colors: Vec<String> = colors.logo.iter().map(|c| c.fg()).collect();
    let logo_lines = if args.no_logo {
        None
    } else if style == Style::Legacy {
        logos::get_logo_lines_styled(&vendor_id, &ansi_logo_colors, false)
    } else {
        logos::get_logo_lines_styled(&vendor_id, &ansi_logo_colors, true)
    };

    let logo_width = logo_lines
        .as_ref()
        .map(|l| l.iter().map(|s| visible_width(s)).max().unwrap_or(0))
        .unwrap_or(0);
    let avail = terminal_width().saturating_sub(logo_width + 3);

    let rendered: Vec<String> = lines
        .iter()
        .flat_map(|(label, value)| {
            let prefix_len = label.len() + 2; // "Label: "
            let wrapped = wrap_value(value, avail.saturating_sub(prefix_len));
            wrapped
                .into_iter()
                .enumerate()
                .map(|(i, part)| {
                    if i == 0 {
                        format!("{}{}: {}{}{}", label_color, label, value_color, part, reset)
                    } else {
                        format!(
                            "{}{}{}{}",
                            " ".repeat(prefix_len),
                            value_color,
                            part,
                            reset
                        )
                    }
                })
                .collect::<Vec<_>>()
        })
        .collect();

    if args.no_logo {
        for line in &rendered {
            println!("{}", line);
        }
        return;
    }

    match logo_lines {
        Some(logo) => print_side_by_side(&logo, &rendered),
        None => {
            for line in &rendered {
                println!("{}", line);
            }
        }
    }
}

fn print_side_by_side(logo: &[String], info: &[String]) {
    let logo_height = logo.len();
    let info_height = info.len();
    let total = logo_height.max(info_height);
    let logo_pad_top = total.saturating_sub(logo_height) / 2;
    let info_pad_top = total.saturating_sub(info_height) / 2;
    let logo_width = logo.iter().map(|l| visible_width(l)).max().unwrap_or(0);

    for i in 0..total {
        let logo_line = if i >= logo_pad_top && i - logo_pad_top < logo_height {
            &logo[i - logo_pad_top]
        } else {
            ""
        };
        let pad = logo_width.saturating_sub(visible_width(logo_line));
        let info_line = if i >= info_pad_top && i - info_pad_top < info_height {
            &info[i - info_pad_top]
        } else {
            ""
        };
        println!("{}{}   {}", logo_line, " ".repeat(pad), info_line);
    }
}

/// Width of a string ignoring ANSI escape sequences.
fn visible_width(s: &str) -> usize {
    let mut width = 0;
    let mut in_escape = false;
    for ch in s.chars() {
        if in_escape {
            if ch == 'm' {
                in_escape = false;
            }
            continue;
        }
        if ch == '\x1b' {
            in_escape = true;
            continue;
        }
        width += 1;
    }
    width
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn format_cache_uses_kib_below_1000() {
        assert_eq!(format_cache(64), "64 KiB");
        assert_eq!(format_cache(999), "999 KiB");
    }

    #[test]
    fn format_cache_switches_to_mib_at_1000() {
        assert_eq!(format_cache(1024), "1.0 MiB");
        assert_eq!(format_cache(12288), "12.0 MiB");
    }

    #[test]
    fn visible_width_ignores_ansi_escapes() {
        assert_eq!(visible_width("\x1b[38;2;1;2;3mhello\x1b[0m"), 5);
        assert_eq!(visible_width("plain"), 5);
    }
}