use crate::metrics::Sample;
pub fn csv_header() -> &'static str {
"timestamp,\
system_processes,system_utime,system_stime,system_cpu_usage,\
system_memory_free_mib,system_memory_used_mib,system_memory_buffers_mib,\
system_memory_cached_mib,system_memory_active_mib,system_memory_inactive_mib,\
system_disk_read_bytes,system_disk_write_bytes,\
system_disk_space_total_gb,system_disk_space_used_gb,system_disk_space_free_gb,\
system_net_recv_bytes,system_net_sent_bytes,\
system_gpu_usage,system_gpu_vram_mib,system_gpu_utilized,\
process_pid,process_children,process_utime,process_stime,process_cpu_usage,\
process_memory_mib,process_disk_read_bytes,process_disk_write_bytes,\
process_gpu_usage,process_gpu_vram_mib,process_gpu_utilized"
}
pub fn sample_to_csv_row(s: &Sample, interval_secs: u64) -> String {
let cpu_usage = s.cpu.utilization_pct;
let secs = f64::from(u32::try_from(interval_secs).unwrap_or(u32::MAX));
let disk_read: u64 = s
.disk
.iter()
.map(|d| (d.read_bytes_per_sec * secs) as u64)
.sum();
let disk_write: u64 = s
.disk
.iter()
.map(|d| (d.write_bytes_per_sec * secs) as u64)
.sum();
let disk_space_total: f64 = s
.disk
.iter()
.flat_map(|d| d.mounts.iter())
.map(|m| m.total_bytes as f64 / 1_000_000_000.0)
.sum();
let disk_space_free: f64 = s
.disk
.iter()
.flat_map(|d| d.mounts.iter())
.map(|m| m.available_bytes as f64 / 1_000_000_000.0)
.sum();
let disk_space_used = disk_space_total - disk_space_free;
let net_recv: u64 = s
.network
.iter()
.map(|n| (n.rx_bytes_per_sec * secs) as u64)
.sum();
let net_sent: u64 = s
.network
.iter()
.map(|n| (n.tx_bytes_per_sec * secs) as u64)
.sum();
let gpu_usage: f64 = s.gpu.iter().map(|g| g.utilization_pct / 100.0).sum();
let gpu_vram: f64 = s
.gpu
.iter()
.map(|g| g.vram_used_bytes as f64 / 1_048_576.0)
.sum();
let gpu_utilized: u32 =
u32::try_from(s.gpu.iter().filter(|g| g.utilization_pct > 0.0).count()).unwrap_or(0);
let system_row = format!(
"{},{},{:.3},{:.3},{:.4},{},{},{},{},{},{},{},{},{:.6},{:.6},{:.6},{},{},{:.4},{:.4},{}",
s.timestamp_secs,
s.cpu.process_count,
s.cpu.utime_secs,
s.cpu.stime_secs,
cpu_usage,
s.memory.free_mib,
s.memory.used_mib,
s.memory.buffers_mib,
s.memory.cached_mib,
s.memory.active_mib,
s.memory.inactive_mib,
disk_read,
disk_write,
disk_space_total,
disk_space_used,
disk_space_free,
net_recv,
net_sent,
gpu_usage,
gpu_vram,
gpu_utilized,
);
let opt_u32 = |v: Option<u32>| v.map_or(String::new(), |x| x.to_string());
let opt_i32 = |v: Option<i32>| v.map_or(String::new(), |x| x.to_string());
let opt_f4 = |v: Option<f64>| v.map_or(String::new(), |x| format!("{x:.4}"));
let opt_u64 = |v: Option<u64>| v.map_or(String::new(), |x| x.to_string());
let process_row = [
opt_i32(s.tracked_pid),
opt_u32(s.cpu.process_child_count),
opt_f4(s.cpu.process_utime_secs),
opt_f4(s.cpu.process_stime_secs),
opt_f4(s.cpu.process_cores_used),
opt_u64(s.cpu.process_rss_mib),
opt_u64(s.cpu.process_disk_read_bytes),
opt_u64(s.cpu.process_disk_write_bytes),
opt_f4(s.cpu.process_gpu_usage),
opt_f4(s.cpu.process_gpu_vram_mib),
opt_u32(s.cpu.process_gpu_utilized),
]
.join(",");
format!("{system_row},{process_row}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metrics::{CpuMetrics, DiskMetrics, DiskMountMetrics, MemoryMetrics, Sample};
fn minimal_sample() -> Sample {
Sample {
timestamp_secs: 1_000_000,
job_name: None,
tracked_pid: None,
cpu: CpuMetrics {
utilization_pct: 2.5,
utime_secs: 1.234,
stime_secs: 0.567,
process_count: 42,
per_core_pct: vec![],
process_cores_used: None,
process_child_count: None,
process_utime_secs: None,
process_stime_secs: None,
process_rss_mib: None,
process_disk_read_bytes: None,
process_disk_write_bytes: None,
process_gpu_usage: None,
process_gpu_vram_mib: None,
process_gpu_utilized: None,
process_tree_pids: vec![],
},
memory: MemoryMetrics {
total_mib: 8192,
free_mib: 1000,
available_mib: 2000,
used_mib: 2000,
used_pct: 25.0,
buffers_mib: 100,
cached_mib: 500,
swap_total_mib: 0,
swap_used_mib: 0,
swap_used_pct: 0.0,
active_mib: 1500,
inactive_mib: 300,
},
network: vec![],
disk: vec![],
gpu: vec![],
}
}
#[test]
fn test_csv_header_is_first_line_no_embedded_newline() {
let h = csv_header();
assert!(
h.starts_with("timestamp,"),
"header must start with 'timestamp,'"
);
assert!(
!h.contains('\n'),
"header must not contain an embedded newline"
);
}
#[test]
fn test_csv_row_column_count_matches_header() {
let header_cols = csv_header().split(',').count();
let row = sample_to_csv_row(&minimal_sample(), 1);
let row_cols = row.split(',').count();
assert_eq!(
row_cols, header_cols,
"header has {header_cols} columns but row has {row_cols}: {row}"
);
}
#[test]
fn test_csv_cpu_usage_is_utilization_pct_direct() {
let mut sample = minimal_sample();
sample.cpu.utilization_pct = 3.1415;
let row = sample_to_csv_row(&sample, 1);
let cols: Vec<&str> = row.split(',').collect();
let cpu_usage: f64 = cols[4]
.parse()
.unwrap_or_else(|_| panic!("system_cpu_usage column is not numeric: {:?}", cols[4]));
assert!(
(cpu_usage - 3.1415_f64).abs() < 0.00005,
"system_cpu_usage {cpu_usage:.4} does not match utilization_pct 3.1415"
);
}
#[test]
fn test_csv_disk_space_used_equals_total_minus_free() {
let mut sample = minimal_sample();
sample.disk = vec![DiskMetrics {
device: "sda".to_string(),
model: None,
vendor: None,
serial: None,
device_type: None,
capacity_bytes: None,
mounts: vec![DiskMountMetrics {
mount_point: "/".to_string(),
filesystem: "ext4".to_string(),
total_bytes: 100_000_000_000,
used_bytes: 60_000_000_000,
available_bytes: 40_000_000_000,
used_pct: 60.0,
}],
read_bytes_per_sec: 0.0,
write_bytes_per_sec: 0.0,
read_bytes_total: 0,
write_bytes_total: 0,
}];
let row = sample_to_csv_row(&sample, 1);
let cols: Vec<&str> = row.split(',').collect();
let total: f64 = cols[13].parse().unwrap();
let used: f64 = cols[14].parse().unwrap();
let free: f64 = cols[15].parse().unwrap();
assert!(
(used - (total - free)).abs() < 1e-9,
"disk_space_used_gb {used:.6} != total {total:.6} - free {free:.6}"
);
}
#[test]
fn test_csv_output_is_deterministic() {
let sample = minimal_sample();
let r1 = sample_to_csv_row(&sample, 1);
let r2 = sample_to_csv_row(&sample, 1);
assert_eq!(r1, r2, "csv row output is not deterministic");
}
#[test]
fn test_csv_process_gpu_fields_emitted_when_set() {
let mut sample = minimal_sample();
sample.tracked_pid = Some(42);
sample.cpu.process_gpu_usage = Some(0.55);
sample.cpu.process_gpu_vram_mib = Some(83.1875);
sample.cpu.process_gpu_utilized = Some(1);
let row = sample_to_csv_row(&sample, 1);
let cols: Vec<&str> = row.split(',').collect();
assert_eq!(cols[29], "0.5500", "process_gpu_usage mismatch");
assert_eq!(cols[30], "83.1875", "process_gpu_vram_mib mismatch");
assert_eq!(cols[31], "1", "process_gpu_utilized mismatch");
}
#[test]
fn test_csv_process_gpu_fields_empty_when_untracked() {
let sample = minimal_sample();
let row = sample_to_csv_row(&sample, 1);
let cols: Vec<&str> = row.split(',').collect();
assert_eq!(cols[29], "", "process_gpu_usage must be empty when None");
assert_eq!(cols[30], "", "process_gpu_vram_mib must be empty when None");
assert_eq!(cols[31], "", "process_gpu_utilized must be empty when None");
}
#[test]
fn test_csv_no_trailing_commas_no_quoted_fields() {
let row = sample_to_csv_row(&minimal_sample(), 1);
assert!(!row.contains('"'), "double-quoted field in row: {row}");
assert!(!row.contains('\''), "single-quoted field in row: {row}");
let h = csv_header();
assert!(!h.ends_with(','), "trailing comma in header");
assert!(!h.contains('"'), "double-quoted field in header");
}
}