Rewrite the logger in Rust; add install targets

vitals-log replaces the bash sampler and writes the same CSV layout.
The crate is now a library plus two binaries. dist/ holds the systemd
unit and desktop entry templates, installed by make install.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 23:46:09 -07:00
co-authored by Claude Fable 5.1
parent 07f68b92a0
commit 2e3f28bc30
9 changed files with 589 additions and 23 deletions
+42
View File
@@ -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)
+50 -10
View File
@@ -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-<timestamp>.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
+6
View File
@@ -0,0 +1,6 @@
[Unit]
Description=Log system temperatures, power status and memory load
[Service]
Type=oneshot
ExecStart=@BINDIR@/vitals-log
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Log system vitals every minute
[Timer]
OnCalendar=minutely
AccuracySec=1s
[Install]
WantedBy=timers.target
+10
View File
@@ -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
+54
View File
@@ -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
}
}
}
+3 -13
View File
@@ -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<f64>) -> 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.
+16
View File
@@ -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")
}
+399
View File
@@ -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<Local>, power_profile: Option<String>) -> 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<u64>| 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<Local>, 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<String> {
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<String> {
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<i64> {
let chip = |name: &str| chips.iter().find(|(n, _)| n == name).map(|(_, dir)| dir);
let input = |path: PathBuf| read(&path)?.parse::<i64>().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<String> {
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<u64>,
available: Option<u64>,
swap_total: Option<u64>,
swap_free: Option<u64>,
}
/// 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<String> {
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<String, String> {
header()
.split(',')
.map(str::to_string)
.zip(line.split(',').map(str::to_string))
.collect()
}
fn now() -> DateTime<Local> {
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(&nothing, 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"));
}
}