diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1291996 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# Per-user install: binaries, the systemd timer that runs the logger, and the +# desktop entry for the viewer. + +PREFIX ?= $(HOME)/.local +BINDIR := $(PREFIX)/bin +APPDIR := $(or $(XDG_DATA_HOME),$(HOME)/.local/share)/applications +UNITDIR := $(or $(XDG_CONFIG_HOME),$(HOME)/.config)/systemd/user + +.PHONY: build test install install-bin install-units install-desktop uninstall + +build: + cargo build --release + +test: + cargo test + +install: install-bin install-units install-desktop + +install-bin: build + install -Dm755 target/release/vitals $(BINDIR)/vitals + install -Dm755 target/release/vitals-log $(BINDIR)/vitals-log + +# dist/ files carry an @BINDIR@ placeholder so they point at wherever PREFIX put the binaries. +install-units: + install -d $(UNITDIR) + sed 's|@BINDIR@|$(BINDIR)|' dist/vitals-log.service > $(UNITDIR)/vitals-log.service + install -m644 dist/vitals-log.timer $(UNITDIR)/vitals-log.timer + systemctl --user daemon-reload + systemctl --user enable --now vitals-log.timer + +install-desktop: + install -d $(APPDIR) + sed 's|@BINDIR@|$(BINDIR)|' dist/vitals.desktop > $(APPDIR)/vitals.desktop + -update-desktop-database $(APPDIR) + +# Leaves the logs in ~/.local/state/vitals alone. +uninstall: + -systemctl --user disable --now vitals-log.timer + rm -f $(UNITDIR)/vitals-log.service $(UNITDIR)/vitals-log.timer + rm -f $(APPDIR)/vitals.desktop $(BINDIR)/vitals $(BINDIR)/vitals-log + -systemctl --user daemon-reload + -update-desktop-database $(APPDIR) diff --git a/README.md b/README.md index d6e7682..4187dbf 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,52 @@ # vitals -Viewer for the CSV logs written by `vitals-log` (`~/.local/bin/vitals-log`, run -every minute by the `vitals-log.timer` systemd user unit). Shows the latest -reading and time-series plots of temperatures, battery, memory, memory pressure -and load. Built with egui and egui_plot. +Logs system temperatures, power status and memory load once a minute, and plots +the history. + +- `vitals-log` takes one sample and appends it to `vitals-YYYY-MM.csv`. A + systemd user timer runs it every minute. +- `vitals` is the viewer: the latest reading plus time-series plots, built with + egui and egui_plot. + +## Install + + make install + +This builds both binaries into `~/.local/bin`, installs and starts +`vitals-log.timer` (systemd user unit), and installs the `Vitals` desktop entry +so the viewer shows up in the launcher. `PREFIX=/somewhere make install` changes +where the binaries go; the unit and desktop entry are generated from the +templates in `dist/` to point at them. The pieces can be installed separately +with `make install-bin`, `install-units` and `install-desktop`. + +`make uninstall` removes all of it and leaves the logs alone. For logging to +continue while logged out, enable lingering once: `loginctl enable-linger`. + +## Logger + + vitals-log [--dir PATH] [--print] + +Logs go to `$XDG_STATE_HOME/vitals` (`~/.local/state/vitals`), one file per +month, about 5 MB each. `--print` writes the header and a sample to stdout +without touching the log, which is the quick way to check what a machine +exposes. + +The columns are fixed and defined in `src/sample.rs`: temperatures come from +hwmon chips looked up by name (the `hwmonN` numbering changes between boots), +power from `/sys/class/power_supply` and `powerprofilesctl`, memory from +`/proc/meminfo` and `/proc/pressure/memory`. The sensor table is specific to +this laptop (Framework, Intel); on other hardware, edit `TEMPERATURES`. A source +that can't be read leaves an empty cell. If the column set changes mid-month, +the old file is renamed `vitals-YYYY-MM.until-.csv` so layouts never +mix in one file. + +## Viewer - cargo install --path . vitals [--dir PATH] [--interval SECONDS] [--range 1h|6h|24h|7d|all] -The log directory defaults to `$XDG_STATE_HOME/vitals` (`~/.local/state/vitals`). -Every `vitals-YYYY-MM.csv` there is loaded at startup; after that only bytes -appended since the last check are read (every 10 s by default). Columns are -matched by header name, so a changed log layout shows gaps rather than breaking. +Every `vitals-YYYY-MM.csv` is loaded at startup; after that only bytes appended +since the last check are read (every 10 s by default). Columns are matched by +header name, so a changed log layout shows gaps rather than breaking. Drag to pan and ctrl-scroll or pinch to zoom the time axis; all plots move together. Picking a range preset returns to following the clock. Lines break @@ -19,6 +54,11 @@ where the logger was not running, and on-battery periods are shaded in the battery plot. Wide ranges are reduced to per-bucket min/max, so spikes stay visible. +## Source + +- `src/sample.rs` — reading sensors, appending to the log - `src/data.rs` — parsed series, windowing, gap splitting, downsampling - `src/tail.rs` — incremental file reader, month rollover, replaced-file reload -- `src/main.rs` — the UI; the `PANELS` table defines the plots +- `src/bin/vitals-log.rs` — the logger's command line +- `src/bin/vitals.rs` — the viewer; the `PANELS` table defines the plots +- `dist/` — systemd unit and desktop entry templates diff --git a/dist/vitals-log.service b/dist/vitals-log.service new file mode 100644 index 0000000..37d5d4a --- /dev/null +++ b/dist/vitals-log.service @@ -0,0 +1,6 @@ +[Unit] +Description=Log system temperatures, power status and memory load + +[Service] +Type=oneshot +ExecStart=@BINDIR@/vitals-log diff --git a/dist/vitals-log.timer b/dist/vitals-log.timer new file mode 100644 index 0000000..2f12528 --- /dev/null +++ b/dist/vitals-log.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Log system vitals every minute + +[Timer] +OnCalendar=minutely +AccuracySec=1s + +[Install] +WantedBy=timers.target diff --git a/dist/vitals.desktop b/dist/vitals.desktop new file mode 100644 index 0000000..f842342 --- /dev/null +++ b/dist/vitals.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Vitals +GenericName=System Vitals History +Comment=Temperature, power and memory history +Exec=@BINDIR@/vitals +Icon=utilities-system-monitor +Terminal=false +Categories=System;Monitor; +StartupWMClass=vitals diff --git a/src/bin/vitals-log.rs b/src/bin/vitals-log.rs new file mode 100644 index 0000000..9fef9d6 --- /dev/null +++ b/src/bin/vitals-log.rs @@ -0,0 +1,54 @@ +//! Append one sample of system vitals to the month's CSV log. Meant to be run +//! every minute by `vitals-log.timer`. + +use std::path::PathBuf; +use std::process::ExitCode; + +use vitals::sample::{Roots, append, power_profile, sample}; + +const USAGE: &str = "Usage: vitals-log [--dir PATH] [--print] + +Appends one line of temperatures, power status and memory load to +vitals-YYYY-MM.csv. + --dir PATH log directory (default: $XDG_STATE_HOME/vitals) + --print write the header and sample to stdout instead of the log"; + +fn main() -> ExitCode { + let mut dir = vitals::default_dir(); + let mut print = false; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--print" => print = true, + "--dir" => match args.next() { + Some(value) => dir = PathBuf::from(value), + None => { + eprintln!("{USAGE}"); + return ExitCode::from(2); + } + }, + "-h" | "--help" => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + _ => { + eprintln!("{USAGE}"); + return ExitCode::from(2); + } + } + } + + let now = chrono::Local::now(); + let line = sample(&Roots::default(), now, power_profile()); + if print { + println!("{}\n{line}", vitals::sample::header()); + return ExitCode::SUCCESS; + } + match append(&dir, now, &line) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("vitals-log: {}: {error}", dir.display()); + ExitCode::FAILURE + } + } +} diff --git a/src/main.rs b/src/bin/vitals.rs similarity index 98% rename from src/main.rs rename to src/bin/vitals.rs index a45ec87..b333fd9 100644 --- a/src/main.rs +++ b/src/bin/vitals.rs @@ -1,8 +1,5 @@ //! Viewer for the CSV files written by `vitals-log`. -mod data; -mod tail; - use std::ops::RangeInclusive; use std::path::PathBuf; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -12,8 +9,9 @@ use eframe::egui::text::{LayoutJob, TextFormat}; use eframe::egui::{self, Color32, RichText, Stroke, Theme}; use egui_plot::{GridInput, GridMark, Line, Plot, PlotPoints, Polygon}; -use data::Series; -use tail::Tailer; +use vitals::data::Series; +use vitals::default_dir; +use vitals::tail::Tailer; /// Points per series per plot before min/max downsampling kicks in. const MAX_BUCKETS: usize = 1000; @@ -511,14 +509,6 @@ fn time_tick(mark: GridMark, _range: &RangeInclusive) -> String { } } -fn default_dir() -> PathBuf { - std::env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(".local/state"))) - .unwrap_or_default() - .join("vitals") -} - const USAGE: &str = "Usage: vitals [--dir PATH] [--interval SECONDS] [--range 1h|6h|24h|7d|all] Plots the CSV files written by vitals-log. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4f02fcd --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,16 @@ +//! Shared by the `vitals-log` sampler and the `vitals` viewer. + +pub mod data; +pub mod sample; +pub mod tail; + +use std::path::PathBuf; + +/// Where the logs live: `$XDG_STATE_HOME/vitals`, normally `~/.local/state/vitals`. +pub fn default_dir() -> PathBuf { + std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(".local/state"))) + .unwrap_or_default() + .join("vitals") +} diff --git a/src/sample.rs b/src/sample.rs new file mode 100644 index 0000000..b419b1f --- /dev/null +++ b/src/sample.rs @@ -0,0 +1,399 @@ +//! Reads one sample of system vitals from sysfs and procfs and appends it to +//! the month's CSV file. + +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Local}; + +/// Filesystem roots, so tests can point the sampler at a fake tree. +pub struct Roots { + pub sys: PathBuf, + pub proc: PathBuf, +} + +impl Default for Roots { + fn default() -> Self { + Self { + sys: "/sys".into(), + proc: "/proc".into(), + } + } +} + +/// Where a temperature column comes from: an hwmon chip name plus either a +/// sensor label or, for chips that don't label their sensors, a sensor index. +enum Sensor { + Label(&'static str, &'static str), + Index(&'static str, u32), + /// Highest of the chip's sensors whose label starts with the prefix. + MaxLabelled(&'static str, &'static str), +} + +const TEMPERATURES: [(&str, Sensor); 18] = [ + ("cpu_pkg_c", Sensor::Label("coretemp", "Package id 0")), + ("cpu_core_max_c", Sensor::MaxLabelled("coretemp", "Core")), + ("ec_cpu_c", Sensor::Label("cros_ec", "cpu_f75303@4d")), + ("ec_top_skin_c", Sensor::Label("cros_ec", "top_skin_f75303@4d")), + ("ec_local_c", Sensor::Label("cros_ec", "local_f75303@4d")), + ("ec_battery_c", Sensor::Label("cros_ec", "battery_temp@b")), + ("ec_peci_c", Sensor::Label("cros_ec", "peci-temp")), + ("ec_charger_c", Sensor::Label("cros_ec", "charger_temp@9")), + ("acpitz1_c", Sensor::Index("acpitz", 1)), + ("acpitz2_c", Sensor::Index("acpitz", 2)), + ("acpitz3_c", Sensor::Index("acpitz", 3)), + ("acpitz4_c", Sensor::Index("acpitz", 4)), + ("acpitz5_c", Sensor::Index("acpitz", 5)), + ("nvme_composite_c", Sensor::Label("nvme", "Composite")), + ("nvme_sensor1_c", Sensor::Label("nvme", "Sensor 1")), + ("nvme_sensor2_c", Sensor::Label("nvme", "Sensor 2")), + ("ram_c", Sensor::Index("spd5118", 1)), + ("wifi_c", Sensor::Index("iwlwifi_1", 1)), +]; + +const OTHER_COLUMNS: [&str; 14] = [ + "ac_online", + "bat_status", + "bat_pct", + "bat_watts", + "power_profile", + "mem_total_mib", + "mem_used_mib", + "mem_avail_mib", + "swap_used_mib", + "psi_some_avg10", + "psi_some_avg60", + "psi_full_avg10", + "psi_full_avg60", + "load1", +]; + +pub fn header() -> String { + let mut names = vec!["timestamp"]; + names.extend(TEMPERATURES.iter().map(|(name, _)| *name)); + names.extend(OTHER_COLUMNS); + names.join(",") +} + +/// One CSV line matching [`header`]. Anything unreadable is an empty cell. +pub fn sample(roots: &Roots, now: DateTime, power_profile: Option) -> String { + let mut cells = vec![now.format("%Y-%m-%dT%H:%M:%S%z").to_string()]; + + let chips = hwmon_chips(&roots.sys); + for (_, sensor) in &TEMPERATURES { + cells.push(temperature(&chips, sensor).map(degrees).unwrap_or_default()); + } + + let supply = roots.sys.join("class/power_supply"); + let battery = supply.join("BAT1"); + let status = read(&battery.join("status")).unwrap_or_default(); + cells.push(read(&supply.join("ACAD/online")).unwrap_or_default()); + cells.push(status.clone()); + cells.push(read(&battery.join("capacity")).unwrap_or_default()); + cells.push(battery_watts(&battery, &status).unwrap_or_default()); + cells.push(power_profile.unwrap_or_default()); + + let mem = meminfo(&roots.proc.join("meminfo")); + let mib = |kib: Option| kib.map(|k| (k / 1024).to_string()).unwrap_or_default(); + cells.push(mib(mem.total)); + cells.push(mib(mem.total.zip(mem.available).map(|(t, a)| t.saturating_sub(a)))); + cells.push(mib(mem.available)); + cells.push(mib(mem.swap_total.zip(mem.swap_free).map(|(t, f)| t.saturating_sub(f)))); + + let psi = fs::read_to_string(roots.proc.join("pressure/memory")).unwrap_or_default(); + for (kind, window) in [("some", "avg10"), ("some", "avg60"), ("full", "avg10"), ("full", "avg60")] { + cells.push(pressure(&psi, kind, window).unwrap_or_default()); + } + + let loadavg = fs::read_to_string(roots.proc.join("loadavg")).unwrap_or_default(); + cells.push(loadavg.split_whitespace().next().unwrap_or_default().to_string()); + + cells.join(",") +} + +/// Append a line to `vitals-YYYY-MM.csv` in `dir`, writing the header first +/// when the file is new. +pub fn append(dir: &Path, now: DateTime, line: &str) -> io::Result<()> { + fs::create_dir_all(dir)?; + let path = dir.join(format!("vitals-{}.csv", now.format("%Y-%m"))); + let header = header(); + + // If the columns changed mid-month, set the old file aside rather than mix layouts. + if let Ok(file) = fs::File::open(&path) { + let mut first = String::new(); + io::BufReader::new(file).read_line(&mut first)?; + if !first.is_empty() && first.trim_end() != header { + let aside = dir.join(format!( + "vitals-{}.until-{}.csv", + now.format("%Y-%m"), + now.format("%Y%m%dT%H%M%S") + )); + fs::rename(&path, aside)?; + } + } + + let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?; + // One write per line keeps concurrent readers from seeing a torn row. + if file.metadata()?.len() == 0 { + file.write_all(format!("{header}\n{line}\n").as_bytes()) + } else { + file.write_all(format!("{line}\n").as_bytes()) + } +} + +/// The active power profile, from power-profiles-daemon. +pub fn power_profile() -> Option { + let output = std::process::Command::new("powerprofilesctl").arg("get").output().ok()?; + let profile = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (output.status.success() && !profile.is_empty()).then_some(profile) +} + +fn read(path: &Path) -> Option { + Some(fs::read_to_string(path).ok()?.trim().to_string()) +} + +/// (chip name, directory) for every hwmon device. The hwmonN numbering changes +/// across boots, so chips are always looked up by name. +fn hwmon_chips(sys: &Path) -> Vec<(String, PathBuf)> { + let Ok(entries) = fs::read_dir(sys.join("class/hwmon")) else { + return Vec::new(); + }; + entries + .filter_map(|entry| { + let dir = entry.ok()?.path(); + Some((read(&dir.join("name"))?, dir)) + }) + .collect() +} + +/// Millidegrees for one sensor. +fn temperature(chips: &[(String, PathBuf)], sensor: &Sensor) -> Option { + let chip = |name: &str| chips.iter().find(|(n, _)| n == name).map(|(_, dir)| dir); + let input = |path: PathBuf| read(&path)?.parse::().ok(); + match *sensor { + Sensor::Index(name, index) => input(chip(name)?.join(format!("temp{index}_input"))), + Sensor::Label(name, label) => labelled(chip(name)?) + .into_iter() + .find(|(l, _)| l == label) + .and_then(|(_, path)| input(path)), + Sensor::MaxLabelled(name, prefix) => labelled(chip(name)?) + .into_iter() + .filter(|(l, _)| l.starts_with(prefix)) + .filter_map(|(_, path)| input(path)) + .max(), + } +} + +/// (label, input path) for each labelled temperature sensor of a chip. +fn labelled(chip: &Path) -> Vec<(String, PathBuf)> { + let Ok(entries) = fs::read_dir(chip) else { + return Vec::new(); + }; + entries + .filter_map(|entry| { + let path = entry.ok()?.path(); + let stem = path.file_name()?.to_str()?.strip_suffix("_label")?.to_string(); + if !stem.starts_with("temp") { + return None; + } + Some((read(&path)?, chip.join(format!("{stem}_input")))) + }) + .collect() +} + +fn degrees(millidegrees: i64) -> String { + format!("{:.1}", millidegrees as f64 / 1000.0) +} + +/// Battery power in watts, negative while discharging. +fn battery_watts(battery: &Path, status: &str) -> Option { + let microamps: f64 = read(&battery.join("current_now"))?.parse().ok()?; + let microvolts: f64 = read(&battery.join("voltage_now"))?.parse().ok()?; + let watts = microamps.abs() * microvolts / 1e12; + let sign = if status == "Discharging" { -1.0 } else { 1.0 }; + Some(format!("{:.2}", sign * watts + 0.0)) +} + +#[derive(Default)] +struct MemInfo { + total: Option, + available: Option, + swap_total: Option, + swap_free: Option, +} + +/// Values in KiB. +fn meminfo(path: &Path) -> MemInfo { + let mut info = MemInfo::default(); + for line in fs::read_to_string(path).unwrap_or_default().lines() { + let mut parts = line.split_whitespace(); + let (Some(key), Some(value)) = (parts.next(), parts.next()) else { + continue; + }; + let value = value.parse().ok(); + match key { + "MemTotal:" => info.total = value, + "MemAvailable:" => info.available = value, + "SwapTotal:" => info.swap_total = value, + "SwapFree:" => info.swap_free = value, + _ => {} + } + } + info +} + +/// One field of /proc/pressure/memory, e.g. ("some", "avg10"). +fn pressure(psi: &str, kind: &str, window: &str) -> Option { + psi.lines() + .find(|line| line.starts_with(kind))? + .split_whitespace() + .find_map(|field| field.strip_prefix(window)?.strip_prefix('=')) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn write(root: &Path, path: &str, content: &str) { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + /// A fake /sys and /proc. hwmon numbers are deliberately out of order. + fn fake_system(root: &Path, battery_status: &str) -> Roots { + let files = [ + ("sys/class/hwmon/hwmon7/name", "acpitz"), + ("sys/class/hwmon/hwmon7/temp1_input", "84800"), + ("sys/class/hwmon/hwmon7/temp2_input", "53900"), + ("sys/class/hwmon/hwmon2/name", "coretemp"), + ("sys/class/hwmon/hwmon2/temp1_label", "Package id 0"), + ("sys/class/hwmon/hwmon2/temp1_input", "76000"), + ("sys/class/hwmon/hwmon2/temp2_label", "Core 0"), + ("sys/class/hwmon/hwmon2/temp2_input", "73000"), + ("sys/class/hwmon/hwmon2/temp6_label", "Core 4"), + ("sys/class/hwmon/hwmon2/temp6_input", "79500"), + ("sys/class/hwmon/hwmon0/name", "cros_ec"), + ("sys/class/hwmon/hwmon0/temp5_label", "peci-temp"), + ("sys/class/hwmon/hwmon0/temp5_input", "84850"), + // A wifi chip whose sensor can't be read (radio off) has no input file. + ("sys/class/hwmon/hwmon9/name", "iwlwifi_1"), + ("sys/class/power_supply/ACAD/online", "0"), + ("sys/class/power_supply/BAT1/status", battery_status), + ("sys/class/power_supply/BAT1/capacity", "87"), + ("sys/class/power_supply/BAT1/current_now", "1500000"), + ("sys/class/power_supply/BAT1/voltage_now", "12000000"), + ( + "proc/meminfo", + "MemTotal: 32576512 kB\nMemFree: 1000000 kB\nMemAvailable: 22606848 kB\nSwapTotal: 4194304 kB\nSwapFree: 3145728 kB\n", + ), + ( + "proc/pressure/memory", + "some avg10=1.25 avg60=0.40 avg300=0.10 total=184\nfull avg10=0.50 avg60=0.05 avg300=0.00 total=97\n", + ), + ("proc/loadavg", "0.91 0.80 0.70 1/1234 5678\n"), + ]; + for (path, content) in files { + write(root, path, &format!("{content}\n")); + } + Roots { + sys: root.join("sys"), + proc: root.join("proc"), + } + } + + fn cells(line: &str) -> std::collections::HashMap { + header() + .split(',') + .map(str::to_string) + .zip(line.split(',').map(str::to_string)) + .collect() + } + + fn now() -> DateTime { + Local.with_ymd_and_hms(2026, 9, 19, 23, 21, 0).unwrap() + } + + #[test] + fn header_matches_the_original_logger() { + assert_eq!( + header(), + "timestamp,cpu_pkg_c,cpu_core_max_c,ec_cpu_c,ec_top_skin_c,ec_local_c,ec_battery_c,ec_peci_c,ec_charger_c,acpitz1_c,acpitz2_c,acpitz3_c,acpitz4_c,acpitz5_c,nvme_composite_c,nvme_sensor1_c,nvme_sensor2_c,ram_c,wifi_c,ac_online,bat_status,bat_pct,bat_watts,power_profile,mem_total_mib,mem_used_mib,mem_avail_mib,swap_used_mib,psi_some_avg10,psi_some_avg60,psi_full_avg10,psi_full_avg60,load1" + ); + } + + #[test] + fn samples_every_source() { + let dir = tempfile::tempdir().unwrap(); + let roots = fake_system(dir.path(), "Discharging"); + let line = sample(&roots, now(), Some("balanced".into())); + let c = cells(&line); + + assert_eq!(line.split(',').count(), header().split(',').count()); + assert!(c["timestamp"].starts_with("2026-09-19T23:21:00")); + assert_eq!(c["cpu_pkg_c"], "76.0"); + assert_eq!(c["cpu_core_max_c"], "79.5"); + assert_eq!(c["ec_peci_c"], "84.8"); + assert_eq!(c["acpitz2_c"], "53.9"); + assert_eq!(c["ac_online"], "0"); + assert_eq!(c["bat_status"], "Discharging"); + assert_eq!(c["bat_pct"], "87"); + assert_eq!(c["bat_watts"], "-18.00"); + assert_eq!(c["power_profile"], "balanced"); + assert_eq!(c["mem_total_mib"], "31813"); + assert_eq!(c["mem_used_mib"], "9736"); + assert_eq!(c["mem_avail_mib"], "22077"); + assert_eq!(c["swap_used_mib"], "1024"); + assert_eq!(c["psi_some_avg10"], "1.25"); + assert_eq!(c["psi_full_avg60"], "0.05"); + assert_eq!(c["load1"], "0.91"); + } + + #[test] + fn unreadable_sources_leave_empty_cells() { + let dir = tempfile::tempdir().unwrap(); + let roots = fake_system(dir.path(), "Charging"); + let c = cells(&sample(&roots, now(), None)); + + assert_eq!(c["wifi_c"], ""); + assert_eq!(c["nvme_composite_c"], ""); + assert_eq!(c["acpitz5_c"], ""); + assert_eq!(c["power_profile"], ""); + assert_eq!(c["bat_watts"], "18.00"); + + let empty = tempfile::tempdir().unwrap(); + let nothing = Roots { + sys: empty.path().join("sys"), + proc: empty.path().join("proc"), + }; + let line = sample(¬hing, now(), None); + assert_eq!(line.split(',').count(), header().split(',').count()); + assert!(line.split(',').skip(1).all(str::is_empty)); + } + + #[test] + fn append_writes_the_header_once() { + let dir = tempfile::tempdir().unwrap(); + append(dir.path(), now(), "row1").unwrap(); + append(dir.path(), now(), "row2").unwrap(); + + let text = fs::read_to_string(dir.path().join("vitals-2026-09.csv")).unwrap(); + assert_eq!(text, format!("{}\nrow1\nrow2\n", header())); + } + + #[test] + fn append_sets_aside_a_file_with_a_different_layout() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("vitals-2026-09.csv"); + fs::write(&log, "timestamp,old_column\n2026-09-01T00:00:00-0700,1\n").unwrap(); + + append(dir.path(), now(), "row1").unwrap(); + + assert_eq!(fs::read_to_string(&log).unwrap(), format!("{}\nrow1\n", header())); + let aside = dir.path().join("vitals-2026-09.until-20260919T232100.csv"); + assert!(fs::read_to_string(aside).unwrap().contains("old_column")); + } +}