use crate::config::Config;
extern "C" {
fn getloadavg(loadavg: *mut f64, nelem: libc::c_int) -> libc::c_int;
}
#[derive(Debug, Clone, PartialEq)]
pub struct SystemSnapshot {
pub cpu_percent: f64,
pub mem_percent: f64,
pub battery: Option<BatteryInfo>,
pub disk_percent: Option<f64>,
pub net: Option<NetThroughput>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BatteryInfo {
pub percent: f64,
pub is_charging: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NetThroughput {
pub rx_bps: f64,
pub tx_bps: f64,
}
fn cpu_percent_from_deltas(busy_delta: u64, total_delta: u64) -> f64 {
if total_delta == 0 {
return 0.0;
}
let percent = 100.0 * (busy_delta as f64) / (total_delta as f64);
percent.clamp(0.0, 100.0)
}
fn loadavg_to_percent(load1: f64, ncpu: u32) -> f64 {
if ncpu == 0 {
return 0.0;
}
let percent = load1 / (ncpu as f64) * 100.0;
percent.clamp(0.0, 100.0)
}
fn cpu_count() -> u32 {
let count = unsafe { libc::sysconf(libc::_SC_NPROCESSORS_ONLN) };
if count > 0 {
count as u32
} else {
1
}
}
fn sample_cpu_loadavg_fallback() -> f64 {
let mut loads = [0.0f64; 3];
let filled = unsafe { getloadavg(loads.as_mut_ptr(), 3) };
if filled < 1 {
return 0.0;
}
loadavg_to_percent(loads[0], cpu_count())
}
struct CpuTickTotals {
busy: u64,
total: u64,
}
fn snapshot_cpu_ticks() -> Option<CpuTickTotals> {
#[allow(deprecated)]
unsafe {
let host = libc::mach_host_self();
let mut cpu_count: libc::natural_t = 0;
let mut info_ptr: libc::processor_info_array_t = std::ptr::null_mut();
let mut info_count: libc::mach_msg_type_number_t = 0;
let result = libc::host_processor_info(
host,
libc::PROCESSOR_CPU_LOAD_INFO,
&mut cpu_count,
&mut info_ptr,
&mut info_count,
);
if result != libc::KERN_SUCCESS || info_ptr.is_null() || cpu_count == 0 {
return None;
}
let states = libc::CPU_STATE_MAX as usize;
let mut busy: u64 = 0;
let mut total: u64 = 0;
for core in 0..(cpu_count as usize) {
let base = core * states;
let user = *info_ptr.add(base + libc::CPU_STATE_USER as usize) as u32 as u64;
let system = *info_ptr.add(base + libc::CPU_STATE_SYSTEM as usize) as u32 as u64;
let nice = *info_ptr.add(base + libc::CPU_STATE_NICE as usize) as u32 as u64;
let idle = *info_ptr.add(base + libc::CPU_STATE_IDLE as usize) as u32 as u64;
busy += user + system + nice;
total += user + system + nice + idle;
}
let dealloc_size = (info_count as usize) * std::mem::size_of::<libc::integer_t>();
libc::vm_deallocate(
libc::mach_task_self_,
info_ptr as libc::vm_address_t,
dealloc_size as libc::vm_size_t,
);
Some(CpuTickTotals { busy, total })
}
}
pub fn sample_cpu_reactive(sample_window_ms: u64) -> f64 {
let first = match snapshot_cpu_ticks() {
Some(snapshot) => snapshot,
None => return sample_cpu_loadavg_fallback(),
};
std::thread::sleep(std::time::Duration::from_millis(sample_window_ms));
let second = match snapshot_cpu_ticks() {
Some(snapshot) => snapshot,
None => return sample_cpu_loadavg_fallback(),
};
let busy_delta = second.busy.saturating_sub(first.busy);
let total_delta = second.total.saturating_sub(first.total);
cpu_percent_from_deltas(busy_delta, total_delta)
}
pub fn sample_memory() -> f64 {
#[allow(deprecated)]
let stats = unsafe {
let host = libc::mach_host_self();
let mut vm_stats: libc::vm_statistics64 = std::mem::zeroed();
let mut count: libc::mach_msg_type_number_t = libc::HOST_VM_INFO64_COUNT;
let result = libc::host_statistics64(
host,
libc::HOST_VM_INFO64,
&mut vm_stats as *mut _ as libc::host_info64_t,
&mut count,
);
if result != libc::KERN_SUCCESS {
return 0.0;
}
vm_stats
};
let used_pages =
stats.active_count as u64 + stats.wire_count as u64 + stats.compressor_page_count as u64;
let free_pages =
stats.free_count as u64 + stats.inactive_count as u64 + stats.speculative_count as u64;
let total_pages = used_pages + free_pages;
if total_pages == 0 {
return 0.0;
}
let percent = 100.0 * (used_pages as f64) / (total_pages as f64);
percent.clamp(0.0, 100.0)
}
const BATTERY_CACHE_FILE: &str = "battery";
const BATTERY_CACHE_TTL_SECONDS: u64 = 30;
extern "C" {
fn IOPSCopyPowerSourcesInfo() -> *const libc::c_void;
fn IOPSCopyPowerSourcesList(blob: *const libc::c_void) -> *const libc::c_void;
fn IOPSGetPowerSourceDescription(
blob: *const libc::c_void,
ps: *const libc::c_void,
) -> *const libc::c_void;
fn CFArrayGetCount(array: *const libc::c_void) -> libc::c_long;
fn CFArrayGetValueAtIndex(array: *const libc::c_void, index: libc::c_long)
-> *const libc::c_void;
fn CFDictionaryGetValue(
dict: *const libc::c_void,
key: *const libc::c_void,
) -> *const libc::c_void;
fn CFNumberGetValue(
number: *const libc::c_void,
the_type: libc::c_int,
value_ptr: *mut libc::c_void,
) -> bool;
fn CFBooleanGetValue(boolean: *const libc::c_void) -> bool;
fn CFStringCreateWithCString(
alloc: *const libc::c_void,
c_str: *const libc::c_char,
encoding: u32,
) -> *const libc::c_void;
fn CFStringCompare(
a: *const libc::c_void,
b: *const libc::c_void,
options: libc::c_ulong,
) -> libc::c_long;
fn CFGetTypeID(cf: *const libc::c_void) -> libc::c_ulong;
fn CFBooleanGetTypeID() -> libc::c_ulong;
fn CFRelease(cf: *const libc::c_void);
}
const CF_NUMBER_SINT64_TYPE: libc::c_int = 4;
const CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
const CF_COMPARE_EQUAL: libc::c_long = 0;
unsafe fn dict_number(dict: *const libc::c_void, key: &std::ffi::CStr) -> Option<i64> {
let cf_key = CFStringCreateWithCString(
std::ptr::null(),
key.as_ptr(),
CF_STRING_ENCODING_UTF8,
);
if cf_key.is_null() {
return None;
}
let value = CFDictionaryGetValue(dict, cf_key);
let result = if value.is_null() {
None
} else {
let mut out: i64 = 0;
let ok = CFNumberGetValue(
value,
CF_NUMBER_SINT64_TYPE,
&mut out as *mut i64 as *mut libc::c_void,
);
if ok {
Some(out)
} else {
None
}
};
CFRelease(cf_key);
result
}
unsafe fn dict_is_charging(dict: *const libc::c_void) -> bool {
if let Ok(key) = std::ffi::CString::new("Is Charging") {
let cf_key =
CFStringCreateWithCString(std::ptr::null(), key.as_ptr(), CF_STRING_ENCODING_UTF8);
if !cf_key.is_null() {
let value = CFDictionaryGetValue(dict, cf_key);
let charging = !value.is_null()
&& CFGetTypeID(value) == CFBooleanGetTypeID()
&& CFBooleanGetValue(value);
CFRelease(cf_key);
if charging {
return true;
}
}
}
dict_string_equals(dict, "Power Source State", "AC Power")
}
unsafe fn dict_string_equals(dict: *const libc::c_void, key: &str, expected: &str) -> bool {
let key_c = match std::ffi::CString::new(key) {
Ok(c) => c,
Err(_) => return false,
};
let expected_c = match std::ffi::CString::new(expected) {
Ok(c) => c,
Err(_) => return false,
};
let cf_key =
CFStringCreateWithCString(std::ptr::null(), key_c.as_ptr(), CF_STRING_ENCODING_UTF8);
if cf_key.is_null() {
return false;
}
let cf_expected = CFStringCreateWithCString(
std::ptr::null(),
expected_c.as_ptr(),
CF_STRING_ENCODING_UTF8,
);
let value = CFDictionaryGetValue(dict, cf_key);
let equal = if !value.is_null() && !cf_expected.is_null() {
CFStringCompare(value, cf_expected, 0) == CF_COMPARE_EQUAL
} else {
false
};
CFRelease(cf_key);
if !cf_expected.is_null() {
CFRelease(cf_expected);
}
equal
}
fn sample_battery_iokit() -> Option<BatteryInfo> {
let current_key = std::ffi::CString::new("Current Capacity").ok()?;
let max_key = std::ffi::CString::new("Max Capacity").ok()?;
unsafe {
let blob = IOPSCopyPowerSourcesInfo();
if blob.is_null() {
return None;
}
let list = IOPSCopyPowerSourcesList(blob);
if list.is_null() {
CFRelease(blob);
return None;
}
let mut result: Option<BatteryInfo> = None;
let count = CFArrayGetCount(list);
for index in 0..count {
let ps = CFArrayGetValueAtIndex(list, index);
if ps.is_null() {
continue;
}
let dict = IOPSGetPowerSourceDescription(blob, ps);
if dict.is_null() {
continue;
}
let current = dict_number(dict, ¤t_key);
let max = dict_number(dict, &max_key);
if let (Some(current), Some(max)) = (current, max) {
if max > 0 {
let percent = (100.0 * current as f64 / max as f64).clamp(0.0, 100.0);
let is_charging = dict_is_charging(dict);
result = Some(BatteryInfo {
percent,
is_charging,
});
break; }
}
}
CFRelease(list);
CFRelease(blob);
result
}
}
fn parse_battery_cache(payload: &str) -> Option<BatteryInfo> {
let mut parts = payload.split_whitespace();
let percent = parts.next()?.parse::<f64>().ok()?;
let charging_flag = parts.next()?;
let is_charging = charging_flag == "1";
Some(BatteryInfo {
percent: percent.clamp(0.0, 100.0),
is_charging,
})
}
fn sample_battery() -> Option<BatteryInfo> {
let now_ms = crate::chain::cache_now_millis();
if let Some((written_ms, payload)) = crate::chain::read_named_cache(BATTERY_CACHE_FILE) {
if crate::chain::is_named_cache_fresh(written_ms, now_ms, BATTERY_CACHE_TTL_SECONDS) {
return parse_battery_cache(&payload);
}
}
let info = sample_battery_iokit()?;
let flag = if info.is_charging { "1" } else { "0" };
crate::chain::write_named_cache(
BATTERY_CACHE_FILE,
now_ms,
&format!("{} {}", info.percent, flag),
);
Some(info)
}
fn disk_percent_from_statfs(blocks: u64, bfree: u64, bavail: u64) -> Option<f64> {
let _ = bfree;
if blocks == 0 {
return None;
}
let used = blocks.saturating_sub(bavail);
let percent = 100.0 * (used as f64) / (blocks as f64);
Some(percent.clamp(0.0, 100.0))
}
fn sample_disk() -> Option<f64> {
let stats = unsafe {
let mut buf: libc::statfs = std::mem::zeroed();
let result = libc::statfs(c"/".as_ptr(), &mut buf);
if result != 0 {
return None;
}
buf
};
disk_percent_from_statfs(stats.f_blocks, stats.f_bfree, stats.f_bavail)
}
fn throughput(prev_bytes: u64, now_bytes: u64, dt_ms: u64) -> Option<f64> {
if dt_ms == 0 {
return None;
}
let delta = now_bytes.saturating_sub(prev_bytes);
let dt_seconds = (dt_ms as f64) / 1000.0;
Some((delta as f64) / dt_seconds)
}
fn sample_net_counters() -> Option<(u64, u64)> {
unsafe {
let mut ifap: *mut libc::ifaddrs = std::ptr::null_mut();
if libc::getifaddrs(&mut ifap) != 0 || ifap.is_null() {
return None;
}
let mut rx_total: u64 = 0;
let mut tx_total: u64 = 0;
let mut cursor = ifap;
while !cursor.is_null() {
let entry = &*cursor;
if !entry.ifa_addr.is_null()
&& (*entry.ifa_addr).sa_family as libc::c_int == libc::AF_LINK
&& (entry.ifa_flags as libc::c_int & libc::IFF_LOOPBACK) == 0
&& !entry.ifa_data.is_null()
{
let data = &*(entry.ifa_data as *const libc::if_data);
rx_total += data.ifi_ibytes as u64;
tx_total += data.ifi_obytes as u64;
}
cursor = entry.ifa_next;
}
libc::freeifaddrs(ifap);
Some((rx_total, tx_total))
}
}
fn sample_net() -> Option<NetThroughput> {
const NET_CACHE_FILE: &str = "net_counters";
let (now_rx, now_tx) = sample_net_counters()?;
let now_ms = crate::chain::cache_now_millis();
let prev = crate::chain::read_named_cache(NET_CACHE_FILE);
crate::chain::write_named_cache(NET_CACHE_FILE, now_ms, &format!("{now_rx} {now_tx}"));
let (prev_ms, payload) = prev?;
let (prev_rx, prev_tx) = parse_net_counters(&payload)?;
let dt_ms = (now_ms.saturating_sub(prev_ms)) as u64;
let rx_bps = throughput(prev_rx, now_rx, dt_ms)?;
let tx_bps = throughput(prev_tx, now_tx, dt_ms)?;
Some(NetThroughput { rx_bps, tx_bps })
}
fn parse_net_counters(payload: &str) -> Option<(u64, u64)> {
let mut parts = payload.split_whitespace();
let rx = parts.next()?.parse::<u64>().ok()?;
let tx = parts.next()?.parse::<u64>().ok()?;
Some((rx, tx))
}
pub fn sample_system(cfg: &Config) -> SystemSnapshot {
SystemSnapshot {
cpu_percent: sample_cpu_reactive(cfg.cpu.sample_window_ms),
mem_percent: sample_memory(),
battery: if cfg.display.show_battery {
sample_battery()
} else {
None
},
disk_percent: if cfg.display.show_disk {
sample_disk()
} else {
None
},
net: if cfg.display.show_network {
sample_net()
} else {
None
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cpu_percent_from_deltas_basic() {
assert_eq!(cpu_percent_from_deltas(300, 1000), 30.0);
}
#[test]
fn cpu_percent_from_deltas_zero_total() {
assert_eq!(cpu_percent_from_deltas(0, 0), 0.0);
assert_eq!(cpu_percent_from_deltas(500, 0), 0.0);
}
#[test]
fn cpu_percent_from_deltas_full() {
assert_eq!(cpu_percent_from_deltas(1000, 1000), 100.0);
}
#[test]
fn cpu_percent_from_deltas_clamps_high() {
assert_eq!(cpu_percent_from_deltas(1500, 1000), 100.0);
}
#[test]
fn loadavg_to_percent_normal() {
assert_eq!(loadavg_to_percent(3.0, 12), 25.0);
}
#[test]
fn loadavg_to_percent_clamps() {
assert_eq!(loadavg_to_percent(68.0, 12), 100.0);
}
#[test]
fn loadavg_to_percent_zero_ncpu() {
assert_eq!(loadavg_to_percent(5.0, 0), 0.0);
}
#[test]
fn live_paths_stay_in_range_without_panic() {
let cpu = sample_cpu_reactive(5);
let mem = sample_memory();
let load = sample_cpu_loadavg_fallback();
assert!((0.0..=100.0).contains(&cpu), "cpu out of range: {cpu}");
assert!((0.0..=100.0).contains(&mem), "mem out of range: {mem}");
assert!(
(0.0..=100.0).contains(&load),
"loadavg out of range: {load}"
);
}
#[test]
fn disk_percent_basic() {
assert_eq!(disk_percent_from_statfs(100, 30, 25), Some(75.0));
}
#[test]
fn disk_percent_zero_blocks_is_none() {
assert_eq!(disk_percent_from_statfs(0, 0, 0), None);
}
#[test]
fn disk_percent_full() {
assert_eq!(disk_percent_from_statfs(1000, 0, 0), Some(100.0));
}
#[test]
fn disk_percent_bavail_exceeds_blocks() {
assert_eq!(disk_percent_from_statfs(100, 200, 200), Some(0.0));
}
#[test]
fn throughput_basic_rate() {
assert_eq!(throughput(1000, 3048, 1000), Some(2048.0));
}
#[test]
fn throughput_half_second_doubles_rate() {
assert_eq!(throughput(0, 1024, 500), Some(2048.0));
}
#[test]
fn throughput_zero_dt_is_none() {
assert_eq!(throughput(0, 1000, 0), None);
}
#[test]
fn throughput_counter_wrap_saturates_to_zero() {
assert_eq!(throughput(5000, 100, 1000), Some(0.0));
}
#[test]
fn parse_net_counters_roundtrip() {
assert_eq!(parse_net_counters("123 456"), Some((123, 456)));
assert_eq!(parse_net_counters("123"), None);
assert_eq!(parse_net_counters(""), None);
assert_eq!(parse_net_counters("abc def"), None);
}
#[test]
fn parse_battery_cache_charging_flag() {
let charging = parse_battery_cache("82.5 1").expect("파싱 성공");
assert_eq!(charging.percent, 82.5);
assert!(charging.is_charging);
let not_charging = parse_battery_cache("40 0").expect("파싱 성공");
assert_eq!(not_charging.percent, 40.0);
assert!(!not_charging.is_charging);
}
#[test]
fn parse_battery_cache_clamps_and_guards() {
assert_eq!(parse_battery_cache("150 1").unwrap().percent, 100.0);
assert_eq!(parse_battery_cache("not-a-number 1"), None);
assert_eq!(parse_battery_cache(""), None);
}
#[test]
fn battery_cache_freshness_gate() {
assert!(crate::chain::is_named_cache_fresh(
0,
29_000,
BATTERY_CACHE_TTL_SECONDS
));
assert!(crate::chain::is_named_cache_fresh(
0,
30_000,
BATTERY_CACHE_TTL_SECONDS
));
assert!(!crate::chain::is_named_cache_fresh(
0,
31_000,
BATTERY_CACHE_TTL_SECONDS
));
}
#[test]
fn live_disk_path_in_range_or_none() {
if let Some(disk) = sample_disk() {
assert!(
(0.0..=100.0).contains(&disk),
"disk out of range: {disk}"
);
}
}
#[test]
fn live_net_counters_no_panic() {
let _ = sample_net_counters();
}
}