use crate::sensor::PowerSensor;
use crate::{Error, Result};
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PowerFormat {
#[default]
Watts,
JoularCore,
}
impl std::str::FromStr for PowerFormat {
type Err = crate::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"watts" => Ok(PowerFormat::Watts),
"joularcore" => Ok(PowerFormat::JoularCore),
other => Err(crate::Error::config(format!(
"unsupported power format {other:?}, expected \"watts\" or \"joularcore\""
))),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct VmConfig {
pub cpu_power_file: Option<PathBuf>,
pub cpu_power_format: PowerFormat,
pub gpu_power_file: Option<PathBuf>,
pub gpu_power_format: PowerFormat,
}
impl VmConfig {
pub fn from_env() -> Result<Option<Self>> {
fn file(var: &str) -> Option<PathBuf> {
std::env::var_os(var)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
fn format(var: &str) -> Result<PowerFormat> {
match std::env::var(var) {
Ok(v) => v.parse(),
Err(_) => Ok(PowerFormat::default()),
}
}
let cpu_power_file = file("VM_CPU_POWER_FILE");
let gpu_power_file = file("VM_GPU_POWER_FILE");
if cpu_power_file.is_none() && gpu_power_file.is_none() {
return Ok(None);
}
Ok(Some(Self {
cpu_power_file,
cpu_power_format: format("VM_CPU_POWER_FORMAT")?,
gpu_power_file,
gpu_power_format: format("VM_GPU_POWER_FORMAT")?,
}))
}
}
const MAX_FILE_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PowerKind {
Cpu,
Gpu,
}
impl PowerKind {
fn columns(self) -> &'static [&'static str] {
match self {
PowerKind::Cpu => &["App Power (W)", "Process Power (W)", "CPU Power (W)"],
PowerKind::Gpu => &["GPU Power (W)"],
}
}
}
struct PowerFile {
path: PathBuf,
format: PowerFormat,
kind: PowerKind,
file: File,
buffer: String,
}
impl PowerFile {
fn open(path: &Path, format: PowerFormat, kind: PowerKind, var_name: &str) -> Result<Self> {
let metadata = std::fs::metadata(path).map_err(|e| {
Error::config(format!(
"{var_name} points to {} which cannot be read: {e}",
path.display()
))
})?;
if !metadata.is_file() {
return Err(Error::config(format!(
"{var_name} points to {} which is not a regular file",
path.display()
)));
}
if metadata.len() > MAX_FILE_BYTES {
return Err(Error::config(format!(
"{var_name} points to {} which holds more than the {MAX_FILE_BYTES} byte limit",
path.display()
)));
}
let file = File::open(path).map_err(|e| {
Error::config(format!(
"{var_name} points to {} which cannot be opened: {e}",
path.display()
))
})?;
Ok(Self {
path: path.to_path_buf(),
format,
kind,
file,
buffer: String::with_capacity(256),
})
}
fn power(&mut self) -> Result<f64> {
let read = |e: std::io::Error| {
Error::sensor("VM power file", format!("{}: {e}", self.path.display()))
};
self.file.seek(SeekFrom::Start(0)).map_err(read)?;
self.buffer.clear();
(&self.file)
.take(MAX_FILE_BYTES + 1)
.read_to_string(&mut self.buffer)
.map_err(read)?;
if self.buffer.len() as u64 > MAX_FILE_BYTES {
return Err(Error::sensor(
"VM power file",
format!(
"{} holds more than the {MAX_FILE_BYTES} byte limit",
self.path.display()
),
));
}
parse_power(&self.buffer, self.format, self.kind).map_err(|detail| {
Error::sensor(
"VM power file",
format!("{}: {detail}", self.path.display()),
)
})
}
}
fn parse_power(
content: &str,
format: PowerFormat,
kind: PowerKind,
) -> std::result::Result<f64, String> {
match format {
PowerFormat::Watts => parse_watts(content),
PowerFormat::JoularCore => parse_joularcore(content, kind),
}
}
fn parse_watts(content: &str) -> std::result::Result<f64, String> {
let line = content.lines().next().unwrap_or("").trim();
if line.is_empty() {
return Ok(0.0);
}
line.parse()
.map_err(|e| format!("expected a number, found {line:?}: {e}"))
}
fn parse_joularcore(content: &str, kind: PowerKind) -> std::result::Result<f64, String> {
let mut lines = content.lines().filter(|line| !line.trim().is_empty());
let Some(header) = lines.next() else {
return Ok(0.0);
};
let Some(data) = lines.next_back() else {
return Ok(0.0);
};
let headers: Vec<&str> = header.split(',').map(str::trim).collect();
let values: Vec<&str> = data.split(',').map(str::trim).collect();
if headers.len() != values.len() {
return Err(format!(
"header has {} columns but the last row has {}",
headers.len(),
values.len()
));
}
kind.columns()
.iter()
.find_map(|wanted| {
let index = headers.iter().position(|header| header == wanted)?;
values[index].parse::<f64>().ok()
})
.ok_or_else(|| {
format!(
"no readable column among {:?} in header {headers:?}",
kind.columns()
)
})
}
pub struct VmSensor(PowerFile);
impl VmSensor {
pub fn cpu(path: impl AsRef<Path>, format: PowerFormat) -> Result<Self> {
Self::open(path, format, PowerKind::Cpu, "VM CPU power file")
}
pub fn gpu(path: impl AsRef<Path>, format: PowerFormat) -> Result<Self> {
Self::open(path, format, PowerKind::Gpu, "VM GPU power file")
}
pub fn cpu_from_config(config: &VmConfig) -> Result<Option<Self>> {
config
.cpu_power_file
.as_ref()
.map(|path| Self::cpu(path, config.cpu_power_format))
.transpose()
}
pub fn gpu_from_config(config: &VmConfig) -> Result<Option<Self>> {
config
.gpu_power_file
.as_ref()
.map(|path| Self::gpu(path, config.gpu_power_format))
.transpose()
}
fn open(
path: impl AsRef<Path>,
format: PowerFormat,
kind: PowerKind,
label: &'static str,
) -> Result<Self> {
Ok(Self(PowerFile::open(path.as_ref(), format, kind, label)?))
}
}
impl PowerSensor for VmSensor {
fn power(&mut self) -> Result<f64> {
self.0.power()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn power_format_defaults_to_watts() {
assert_eq!(PowerFormat::default(), PowerFormat::Watts);
assert_eq!(
"JoularCore".parse::<PowerFormat>().unwrap(),
PowerFormat::JoularCore
);
assert!("csv".parse::<PowerFormat>().is_err());
}
#[test]
fn watts_reads_a_bare_number() {
assert_eq!(parse_watts("42.5\n").unwrap(), 42.5);
assert_eq!(parse_watts(" 7 \n").unwrap(), 7.0);
assert_eq!(parse_watts("").unwrap(), 0.0);
assert!(parse_watts("not a number").is_err());
}
const JOULARCORE_CSV: &str = "\
Timestamp,Total Power (W),CPU Power (W),GPU Power (W),CPU Usage (%),App Power (W),App PIDs
1700000000,20.00,15.00,5.00,30.00,4.00,2
1700000001,22.00,17.00,5.00,35.00,6.00,3
";
#[test]
fn joularcore_reads_the_last_row() {
assert_eq!(
parse_joularcore(JOULARCORE_CSV, PowerKind::Cpu).unwrap(),
6.0
);
assert_eq!(
parse_joularcore(JOULARCORE_CSV, PowerKind::Gpu).unwrap(),
5.0
);
}
#[test]
fn joularcore_falls_back_through_the_column_priority() {
let csv = "Timestamp,CPU Power (W),GPU Power (W)\n1700000000,15.00,5.00\n";
assert_eq!(parse_joularcore(csv, PowerKind::Cpu).unwrap(), 15.0);
}
#[test]
fn joularcore_rejects_a_ragged_row() {
let csv = "Timestamp,CPU Power (W),GPU Power (W)\n1700000000,15.00\n";
assert!(parse_joularcore(csv, PowerKind::Cpu).is_err());
}
#[test]
fn joularcore_errors_when_no_column_matches() {
let csv = "Timestamp,Temperature (C)\n1700000000,55\n";
assert!(parse_joularcore(csv, PowerKind::Cpu).is_err());
}
#[test]
fn joularcore_tolerates_a_header_only_file() {
assert_eq!(
parse_joularcore("Timestamp,CPU Power (W)\n", PowerKind::Cpu).unwrap(),
0.0
);
assert_eq!(parse_joularcore("", PowerKind::Cpu).unwrap(), 0.0);
}
#[test]
fn reader_reflects_later_writes_to_the_same_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("power");
std::fs::write(&path, "10.0\n").unwrap();
let mut cpu = VmSensor::cpu(&path, PowerFormat::Watts).unwrap();
assert_eq!(cpu.power().unwrap(), 10.0);
std::fs::write(&path, "25.5\n").unwrap();
assert_eq!(cpu.power().unwrap(), 25.5);
}
#[test]
fn opening_an_oversized_file_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("power");
std::fs::write(&path, "x".repeat(MAX_FILE_BYTES as usize + 1)).unwrap();
let Err(error) = VmSensor::cpu(&path, PowerFormat::Watts) else {
panic!("expected oversized file to be rejected");
};
assert!(matches!(error, Error::Config(_)));
}
#[test]
fn a_file_that_grows_past_the_cap_is_unavailable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("power");
std::fs::write(&path, "10.0\n").unwrap();
let mut cpu = VmSensor::cpu(&path, PowerFormat::Watts).unwrap();
assert_eq!(cpu.power().unwrap(), 10.0);
std::fs::write(
&path,
format!("42.0\n{}", "x".repeat(MAX_FILE_BYTES as usize)),
)
.unwrap();
let error = cpu.power().unwrap_err();
assert!(matches!(error, Error::SensorUnavailable { .. }));
}
#[test]
fn opening_a_directory_is_rejected() {
let dir = tempfile::tempdir().unwrap();
assert!(VmSensor::cpu(dir.path(), PowerFormat::Watts).is_err());
}
#[test]
fn opening_a_missing_file_is_rejected() {
assert!(VmSensor::gpu("/nonexistent/joularcore/power", PowerFormat::Watts).is_err());
}
}