use anyhow::Result;
#[derive(Debug, Clone)]
pub struct MemorySnapshot {
pub available_mb: u64,
pub total_mb: u64,
}
impl MemorySnapshot {
pub fn usage_percent(&self) -> u8 {
if self.total_mb == 0 {
return 0;
}
let used = self.total_mb.saturating_sub(self.available_mb);
let pct = (used * 100) / self.total_mb;
u8::try_from(pct.min(100)).unwrap_or(100)
}
}
pub trait MemoryReader: Send + Sync {
fn read_memory(&self) -> Result<MemorySnapshot>;
}
pub struct SystemMemoryReader;
impl MemoryReader for SystemMemoryReader {
fn read_memory(&self) -> Result<MemorySnapshot> {
read_system_memory()
}
}
#[cfg(target_os = "macos")]
fn read_system_memory() -> Result<MemorySnapshot> {
let total_output = std::process::Command::new("sysctl")
.args(["-n", "hw.memsize"])
.output()?;
let vm_output = std::process::Command::new("vm_stat").output()?;
let sysctl_text = String::from_utf8_lossy(&total_output.stdout);
let vm_text = String::from_utf8_lossy(&vm_output.stdout);
parse_macos_memory(&sysctl_text, &vm_text)
}
#[cfg(target_os = "macos")]
fn parse_macos_memory(sysctl_text: &str, vm_text: &str) -> Result<MemorySnapshot> {
let total_bytes: u64 = sysctl_text
.trim()
.parse()
.map_err(|e| anyhow::anyhow!("cannot parse sysctl output {sysctl_text:?}: {e}"))?;
let total_mb = total_bytes / (1024 * 1024);
let page_size = parse_vm_stat_page_size(vm_text)?;
let free_pages = parse_vm_stat_field(vm_text, "Pages free")?;
let inactive_pages = parse_vm_stat_field(vm_text, "Pages inactive")?;
let purgeable_pages = parse_vm_stat_field(vm_text, "Pages purgeable").unwrap_or(0);
let available_bytes = (free_pages + inactive_pages + purgeable_pages) * page_size;
let available_mb = available_bytes / (1024 * 1024);
Ok(MemorySnapshot {
available_mb,
total_mb,
})
}
#[cfg(target_os = "macos")]
fn parse_vm_stat_page_size(text: &str) -> Result<u64> {
let first_line = text
.lines()
.next()
.ok_or_else(|| anyhow::anyhow!("empty vm_stat output"))?;
let size_str = first_line
.split("page size of ")
.nth(1)
.and_then(|s| s.split_whitespace().next())
.ok_or_else(|| anyhow::anyhow!("cannot parse page size from: {first_line}"))?;
size_str
.trim_end_matches(')')
.parse()
.map_err(|e| anyhow::anyhow!("invalid page size {size_str:?}: {e}"))
}
#[cfg(target_os = "macos")]
fn parse_vm_stat_field(text: &str, field: &str) -> Result<u64> {
for line in text.lines() {
if line.starts_with(field) {
let raw = line
.split(':')
.nth(1)
.ok_or_else(|| anyhow::anyhow!("no colon in field line: {line}"))?;
let digits: String = raw.chars().filter(char::is_ascii_digit).collect();
if digits.is_empty() {
anyhow::bail!("no numeric value in field line: {line}");
}
return Ok(digits.parse()?);
}
}
anyhow::bail!("field not found in vm_stat: {field}")
}
#[cfg(target_os = "linux")]
fn read_system_memory() -> Result<MemorySnapshot> {
let content = std::fs::read_to_string("/proc/meminfo")?;
let total_kb = parse_meminfo_field(&content, "MemTotal")?;
let available_kb = parse_meminfo_field(&content, "MemAvailable")?;
Ok(MemorySnapshot {
available_mb: available_kb / 1024,
total_mb: total_kb / 1024,
})
}
#[cfg(target_os = "linux")]
fn parse_meminfo_field(content: &str, field: &str) -> Result<u64> {
for line in content.lines() {
if line.starts_with(field) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
return Ok(parts[1].parse()?);
}
}
}
anyhow::bail!("field not found in /proc/meminfo: {field}")
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn read_system_memory() -> Result<MemorySnapshot> {
Ok(MemorySnapshot {
available_mb: 0,
total_mb: 0,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_usage_percent_normal() {
let snap = MemorySnapshot {
available_mb: 2048,
total_mb: 8192,
};
assert_eq!(snap.usage_percent(), 75);
}
#[test]
fn test_usage_percent_zero_total() {
let snap = MemorySnapshot {
available_mb: 100,
total_mb: 0,
};
assert_eq!(snap.usage_percent(), 0);
}
#[test]
fn test_usage_percent_full() {
let snap = MemorySnapshot {
available_mb: 0,
total_mb: 8192,
};
assert_eq!(snap.usage_percent(), 100);
}
#[test]
fn test_usage_percent_empty() {
let snap = MemorySnapshot {
available_mb: 8192,
total_mb: 8192,
};
assert_eq!(snap.usage_percent(), 0);
}
#[test]
fn test_usage_percent_available_exceeds_total() {
let snap = MemorySnapshot {
available_mb: 10000,
total_mb: 8192,
};
assert_eq!(snap.usage_percent(), 0);
}
#[test]
fn test_usage_percent_rounding() {
let snap = MemorySnapshot {
available_mb: 1000,
total_mb: 3000,
};
assert_eq!(snap.usage_percent(), 66);
}
#[test]
fn test_memory_snapshot_debug() {
let snap = MemorySnapshot {
available_mb: 4096,
total_mb: 16384,
};
let debug = format!("{snap:?}");
assert!(debug.contains("4096"));
assert!(debug.contains("16384"));
}
#[test]
fn test_memory_snapshot_clone() {
let snap = MemorySnapshot {
available_mb: 2048,
total_mb: 8192,
};
#[allow(clippy::redundant_clone)]
let cloned = snap.clone();
assert_eq!(cloned.available_mb, 2048);
assert_eq!(cloned.total_mb, 8192);
}
#[test]
fn test_system_memory_reader_reads() {
let reader = SystemMemoryReader;
let result = reader.read_memory();
let snap = result.unwrap();
#[cfg(any(target_os = "macos", target_os = "linux"))]
assert!(snap.total_mb > 0);
assert!(snap.usage_percent() <= 100);
}
#[cfg(target_os = "macos")]
mod macos_tests {
use super::super::*;
#[test]
fn test_parse_vm_stat_page_size() {
let text = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n\
Pages free: 1234.\n";
assert_eq!(parse_vm_stat_page_size(text).unwrap(), 16384);
}
#[test]
fn test_parse_vm_stat_page_size_4k() {
let text = "Mach Virtual Memory Statistics: (page size of 4096 bytes)\n";
assert_eq!(parse_vm_stat_page_size(text).unwrap(), 4096);
}
#[test]
fn test_parse_vm_stat_page_size_empty() {
let result = parse_vm_stat_page_size("");
assert!(result.is_err());
}
#[test]
fn test_parse_vm_stat_page_size_no_match() {
let result = parse_vm_stat_page_size("Some other text");
assert!(result.is_err());
}
#[test]
fn test_parse_vm_stat_field() {
let text = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n\
Pages free: 5000.\n\
Pages active: 10000.\n\
Pages inactive: 3000.\n\
Pages purgeable: 500.\n";
assert_eq!(parse_vm_stat_field(text, "Pages free").unwrap(), 5000);
assert_eq!(parse_vm_stat_field(text, "Pages inactive").unwrap(), 3000);
assert_eq!(parse_vm_stat_field(text, "Pages purgeable").unwrap(), 500);
}
#[test]
fn test_parse_vm_stat_field_not_found() {
let text = "Pages free: 100.\n";
let result = parse_vm_stat_field(text, "Pages nonexistent");
assert!(result.is_err());
}
#[test]
fn test_parse_vm_stat_field_no_colon() {
let text = "Pages free no colon here\n";
let result = parse_vm_stat_field(text, "Pages free");
assert!(result.is_err());
}
#[test]
fn test_parse_vm_stat_field_extra_whitespace() {
let text = "Pages free: 5000. \n";
assert_eq!(parse_vm_stat_field(text, "Pages free").unwrap(), 5000);
}
#[test]
fn test_parse_vm_stat_field_no_trailing_dot() {
let text = "Pages free: 5000\n";
assert_eq!(parse_vm_stat_field(text, "Pages free").unwrap(), 5000);
}
#[test]
fn test_parse_vm_stat_field_empty_value() {
let text = "Pages free: \n";
let result = parse_vm_stat_field(text, "Pages free");
assert!(result.is_err());
}
#[test]
fn test_parse_vm_stat_page_size_extra_whitespace() {
let text = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n";
assert_eq!(parse_vm_stat_page_size(text).unwrap(), 16384);
}
#[test]
fn test_parse_vm_stat_page_size_trailing_paren() {
let text = "Mach Virtual Memory Statistics: (page size of 16384)\n";
assert_eq!(parse_vm_stat_page_size(text).unwrap(), 16384);
}
#[test]
fn test_parse_vm_stat_page_size_invalid_number() {
let text = "Mach Virtual Memory Statistics: (page size of abc bytes)\n";
let result = parse_vm_stat_page_size(text);
assert!(result.is_err());
}
#[test]
fn test_parse_macos_memory_valid() {
let sysctl = "17179869184\n"; let vm_stat = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n\
Pages free: 5000.\n\
Pages inactive: 3000.\n\
Pages purgeable: 500.\n";
let snap = parse_macos_memory(sysctl, vm_stat).unwrap();
assert_eq!(snap.total_mb, 16384);
assert_eq!(snap.available_mb, 132);
}
#[test]
fn test_parse_macos_memory_invalid_sysctl() {
let result = parse_macos_memory("not-a-number\n", "");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("cannot parse sysctl")
);
}
#[test]
fn test_parse_macos_memory_empty_sysctl() {
let result = parse_macos_memory("", "");
assert!(result.is_err());
}
#[test]
fn test_parse_macos_memory_invalid_vmstat() {
let result = parse_macos_memory("17179869184\n", "garbage output\n");
assert!(result.is_err());
}
}
#[cfg(target_os = "linux")]
mod linux_tests {
use super::super::*;
#[test]
fn test_parse_meminfo_field() {
let content = "MemTotal: 16384000 kB\n\
MemFree: 4096000 kB\n\
MemAvailable: 8192000 kB\n";
assert_eq!(
parse_meminfo_field(content, "MemTotal").unwrap(),
16_384_000
);
assert_eq!(
parse_meminfo_field(content, "MemAvailable").unwrap(),
8_192_000
);
}
#[test]
fn test_parse_meminfo_field_not_found() {
let content = "MemTotal: 1000 kB\n";
let result = parse_meminfo_field(content, "MemAvailable");
assert!(result.is_err());
}
}
}