use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::chart::{BrailleGraph, draw_notice, meter_line, percent};
use crate::config::DiskConfig;
use crate::frame::{Binding, FRAME_HEIGHT};
use crate::keymap::{KeysConfig, Meta, PanelKeymap};
use crate::panel::{Panel, RenderContext};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Volume {
pub mount: String,
pub name: String,
pub file_system: String,
pub total: u64,
pub available: u64,
pub read_only: bool,
pub read_rate: u64,
pub write_rate: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Device {
pub mount: String,
pub total: u64,
pub available: u64,
pub read_rate: u64,
pub write_rate: u64,
}
impl Device {
fn used(&self) -> u64 {
self.total.saturating_sub(self.available)
}
fn used_pct(&self) -> u16 {
percent(self.used(), self.total)
}
}
pub(crate) fn human(bytes: u64) -> String {
const TB: f64 = 1e12;
const GB: f64 = 1e9;
const MB: f64 = 1e6;
let b = bytes as f64;
if b >= TB {
format!("{:.1} TB", b / TB)
} else if b >= 10.0 * GB {
format!("{:.0} GB", b / GB)
} else if b >= GB {
format!("{:.1} GB", b / GB)
} else if b >= MB {
format!("{:.0} MB", b / MB)
} else {
format!("{} kB", bytes / 1000)
}
}
fn rate(bytes_per_sec: u64) -> String {
const UNITS: [(u128, &str); 4] = [
(1_000, "kB"),
(1_000_000, "MB"),
(1_000_000_000, "GB"),
(1_000_000_000_000, "TB"),
];
let bytes = u128::from(bytes_per_sec);
let whole = |unit: u128| (bytes + unit / 2) / unit;
let tenths = |unit: u128| (bytes * 10 + unit / 2) / unit;
let (unit, name) = UNITS
.into_iter()
.find(|&(unit, _)| whole(unit) < 1_000)
.unwrap_or(UNITS[3]);
match tenths(unit) {
0 => format!("0 {name}/s"),
t if t < 100 => format!("{}.{} {name}/s", t / 10, t % 10),
_ => format!("{} {name}/s", whole(unit)),
}
}
const NOT_A_DISK: &[&str] = &[
"squashfs", "iso9660", "udf", "tmpfs", "devtmpfs", "ramfs", "autofs", "overlay",
];
const DRIFT_FLOOR: u64 = 128 * 1024 * 1024;
fn fold_tolerance(total: u64) -> u64 {
(total / 10_000).max(DRIFT_FLOOR.min(total / 100))
}
fn same_device(total: u64, available: u64, name: &str, v: &Volume) -> bool {
if total != v.total {
return false;
}
let drift = available.abs_diff(v.available);
drift <= fold_tolerance(total) || (!name.is_empty() && name == v.name && drift <= total / 100)
}
pub(crate) fn group(volumes: Vec<Volume>) -> Vec<Device> {
let mut devices: Vec<(Device, String, bool)> = Vec::new();
for v in volumes {
if v.total == 0 || NOT_A_DISK.contains(&v.file_system.to_ascii_lowercase().as_str()) {
continue;
}
match devices
.iter_mut()
.find(|(d, name, _)| same_device(d.total, d.available, name, &v))
{
Some((d, _, writable)) => {
if shorter(&v.mount, &d.mount) {
d.mount = v.mount;
}
d.available = d.available.min(v.available);
d.read_rate = d.read_rate.max(v.read_rate);
d.write_rate = d.write_rate.max(v.write_rate);
*writable |= !v.read_only;
}
None => devices.push((
Device {
mount: v.mount,
total: v.total,
available: v.available,
read_rate: v.read_rate,
write_rate: v.write_rate,
},
v.name,
!v.read_only,
)),
}
}
if devices.iter().any(|(_, _, writable)| *writable) {
devices.retain(|(_, _, writable)| *writable);
}
let mut out: Vec<Device> = devices.into_iter().map(|(d, _, _)| d).collect();
out.sort_by(|a, b| shorter_order(&a.mount, &b.mount));
out
}
fn shorter(a: &str, b: &str) -> bool {
shorter_order(a, b) == std::cmp::Ordering::Less
}
fn shorter_order(a: &str, b: &str) -> std::cmp::Ordering {
a.chars()
.count()
.cmp(&b.chars().count())
.then_with(|| a.cmp(b))
}
struct Reader {
disks: sysinfo::Disks,
last: Instant,
last_listing: Instant,
relist_every: Duration,
}
impl Reader {
fn new(relist_every: Duration) -> Self {
use sysinfo::{DiskRefreshKind, Disks};
let disks = Disks::new_with_refreshed_list_specifics(
DiskRefreshKind::nothing().with_storage().with_io_usage(),
);
let now = Instant::now();
Self {
disks,
last: now,
last_listing: now,
relist_every,
}
}
fn read(&mut self) -> Vec<Volume> {
use sysinfo::DiskRefreshKind;
let now = Instant::now();
let elapsed = now.duration_since(self.last).as_secs_f64().max(0.001);
let relist = now.duration_since(self.last_listing) >= self.relist_every;
let kind = if relist {
DiskRefreshKind::nothing().with_storage().with_io_usage()
} else {
DiskRefreshKind::nothing().with_io_usage()
};
self.disks.refresh_specifics(relist, kind);
if relist {
self.last_listing = now;
}
self.last = now;
let per_second = |bytes: u64| (bytes as f64 / elapsed) as u64;
self.disks
.list()
.iter()
.map(|d| Volume {
mount: d.mount_point().to_string_lossy().into_owned(),
name: d.name().to_string_lossy().into_owned(),
file_system: d.file_system().to_string_lossy().into_owned(),
total: d.total_space(),
available: d.available_space(),
read_only: d.is_read_only(),
read_rate: per_second(d.usage().read_bytes),
write_rate: per_second(d.usage().written_bytes),
})
.collect()
}
}
const SCALE_FLOOR: u64 = 1_000_000;
#[derive(Debug)]
pub struct DiskPanel {
config: DiskConfig,
keys: PanelKeymap<DiskAction>,
shared: Arc<Mutex<Option<Vec<Device>>>>,
generation: Arc<AtomicU64>,
seen: u64,
stop: Arc<AtomicBool>,
cache: Option<Vec<Device>>,
histories: HashMap<String, (VecDeque<u64>, VecDeque<u64>)>,
graph_cells: usize,
}
impl DiskPanel {
pub fn new(config: DiskConfig) -> Self {
let shared: Arc<Mutex<Option<Vec<Device>>>> = Arc::new(Mutex::new(None));
let generation = Arc::new(AtomicU64::new(0));
let stop = Arc::new(AtomicBool::new(false));
let interval = Duration::from_secs(config.io_sample_secs.max(1));
let relist_every = Duration::from_secs(config.sample_secs.max(1));
let (state, bump, halt) = (
Arc::clone(&shared),
Arc::clone(&generation),
Arc::clone(&stop),
);
std::thread::Builder::new()
.name("mirador-disk".into())
.spawn(move || {
let mut reader = Reader::new(relist_every);
loop {
let devices = group(reader.read());
if let Ok(mut slot) = state.lock() {
*slot = Some(devices);
}
bump.fetch_add(1, Ordering::Release);
if matches!(
crate::poll::wait(interval, &halt, || false),
crate::poll::Wake::Stop
) {
break;
}
}
})
.expect("spawning the disk thread");
Self {
keys: PanelKeymap::or_defaults("disk", ACTIONS, &config.keys),
config,
shared,
generation,
seen: 0,
stop,
cache: None,
histories: HashMap::new(),
graph_cells: 0,
}
}
#[cfg(test)]
pub(crate) fn with_devices(config: DiskConfig, devices: Vec<Device>) -> Self {
let mut panel = Self {
keys: PanelKeymap::or_defaults("disk", ACTIONS, &config.keys),
config,
shared: Arc::new(Mutex::new(None)),
generation: Arc::new(AtomicU64::new(0)),
seen: 0,
stop: Arc::new(AtomicBool::new(false)),
cache: None,
histories: HashMap::new(),
graph_cells: 0,
};
panel.take(devices);
panel
}
fn take(&mut self, devices: Vec<Device>) {
let capacity = crate::samples::capacity(self.config.history, self.graph_cells);
for device in &devices {
let (reads, writes) = self.histories.entry(device.mount.clone()).or_default();
crate::samples::push_bounded(reads, device.read_rate, capacity);
crate::samples::push_bounded(writes, device.write_rate, capacity);
}
self.histories
.retain(|mount, _| devices.iter().any(|d| &d.mount == mount));
self.cache = Some(devices);
}
fn scale(&self, mount: &str) -> u64 {
self.histories
.get(mount)
.map_or(0, |(r, w)| {
r.iter().chain(w.iter()).copied().max().unwrap_or(0)
})
.max(SCALE_FLOOR)
}
#[cfg(test)]
pub(crate) fn canned(config: DiskConfig) -> Self {
Self::with_devices(
config,
vec![
Device {
mount: "/".into(),
total: 1_995_165_736_960,
available: 1_637_809_513_970,
read_rate: 0,
write_rate: 327_680,
},
Device {
mount: "/Volumes/T7".into(),
total: 2_000_398_934_016,
available: 61_203_144_704,
read_rate: 52_428_800,
write_rate: 0,
},
],
)
}
fn colour(&self, pct: u16, theme: &crate::theme::Theme) -> Color {
if self.config.alert_above_pct > 0 && pct >= self.config.alert_above_pct {
theme.error
} else if self.config.warn_above_pct > 0 && pct >= self.config.warn_above_pct {
theme.warning
} else {
theme.accent
}
}
fn devices(&self) -> &[Device] {
self.cache.as_deref().unwrap_or(&[])
}
}
impl Drop for DiskPanel {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiskAction {
Io,
}
pub const ACTIONS: &[Meta<DiskAction>] = &[Meta {
action: DiskAction::Io,
name: "io",
defaults: &[(KeyCode::Char('i'), KeyModifiers::NONE)],
label: "i/o",
primary: true,
joins: false,
about: "show or hide the I/O graphs",
}];
pub fn keymap(keys: &KeysConfig) -> Result<PanelKeymap<DiskAction>, String> {
PanelKeymap::new("disk", ACTIONS, keys)
}
fn rows_per_device(height: u16, devices: u16) -> u16 {
if devices == 0 {
return 1;
}
if height >= devices * 3 - 1 {
3
} else if height >= devices * 2 {
2
} else {
1
}
}
impl Panel for DiskPanel {
fn title(&self) -> String {
"Disk".to_string()
}
fn counter(&self) -> Option<String> {
let first = self.devices().first()?;
Some(format!("{} free", human(first.available)))
}
fn bindings(&self) -> &[Binding] {
self.keys.bindings()
}
fn set_keys(&mut self, config: &crate::config::Config) {
self.keys = PanelKeymap::or_defaults("disk", ACTIONS, &config.disk.keys);
}
fn max_height(&self) -> Option<u16> {
if self.config.show_io {
return None;
}
let devices = u16::try_from(self.devices().len()).unwrap_or(u16::MAX);
(devices > 0).then(|| FRAME_HEIGHT + devices * 3 - 1)
}
fn refresh_interval(&self) -> Duration {
Duration::from_secs(1)
}
fn tick(&mut self) -> bool {
let now = self.generation.load(Ordering::Acquire);
if now == self.seen {
return false;
}
self.seen = now;
let fresh = self.shared.lock().ok().and_then(|slot| slot.clone());
if let Some(devices) = fresh {
self.take(devices);
}
true
}
fn handle_key(&mut self, key: ratatui::crossterm::event::KeyEvent) -> crate::panel::KeyOutcome {
if self.keys.action(key) == Some(DiskAction::Io) {
self.config.show_io = !self.config.show_io;
return crate::panel::KeyOutcome::Consumed;
}
crate::panel::KeyOutcome::Ignored
}
fn alert(&self) -> Option<crate::panel::Alert> {
if self.config.alert_above_pct == 0 {
return None;
}
let fullest = self
.devices()
.iter()
.filter(|d| d.used_pct() >= self.config.alert_above_pct)
.max_by_key(|d| d.used_pct())?;
Some(crate::panel::Alert::soon(format!(
"Disk {} at {}% — {} free",
fullest.mount,
fullest.used_pct(),
human(fullest.available)
)))
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
let theme = ctx.theme;
if area.width == 0 || area.height == 0 {
return;
}
let Some(devices) = self.cache.as_deref() else {
draw_notice(frame, area, theme, &["Reading disks", ""]);
return;
};
if devices.is_empty() {
draw_notice(
frame,
area,
theme,
&["No disks readable", "Nothing listed can fill up"],
);
return;
}
let count = u16::try_from(devices.len()).unwrap_or(u16::MAX);
let show_io = self.config.show_io && area.height >= 3;
if show_io {
self.graph_cells = usize::from(area.width);
let regions = Layout::vertical(vec![Constraint::Fill(1); devices.len()]).split(area);
for (device, region) in devices.iter().zip(regions.iter()) {
self.draw_device_with_io(frame, *region, device, ctx);
}
return;
}
let bottom = area.y + area.height;
let per = rows_per_device(area.height, count);
let mut y = area.y;
for (index, device) in devices.iter().enumerate() {
if y >= bottom {
break;
}
let pct = device.used_pct();
let colour = self.colour(pct, theme);
frame.render_widget(
Paragraph::new(crate::grid::assemble(
figures(device, colour, theme),
area.width,
)),
Rect::new(area.x, y, area.width, 1),
);
y += 1;
if per >= 2 && y < bottom {
frame.render_widget(
Paragraph::new(Line::from(meter_line(pct, area.width, colour, theme.track))),
Rect::new(area.x, y, area.width, 1),
);
y += 1;
}
if per == 3 && index + 1 < devices.len() {
y += 1;
}
}
}
}
fn figures(device: &Device, colour: Color, theme: &crate::theme::Theme) -> Vec<Vec<Span<'static>>> {
let muted = Style::default().fg(theme.muted);
let pct = device.used_pct();
vec![
vec![Span::styled(
device.mount.clone(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
)],
vec![
Span::styled(
format!(" {pct}"),
Style::default().fg(colour).add_modifier(Modifier::BOLD),
),
Span::styled("%", muted),
],
vec![Span::styled(
format!(" {}", crate::glyphs::utility("used")),
Style::default()
.fg(theme.label)
.add_modifier(Modifier::BOLD),
)],
vec![Span::styled(
format!(" {} free", human(device.available)),
muted,
)],
vec![Span::styled(
format!(" of {}", human(device.total)),
muted,
)],
]
}
impl DiskPanel {
fn draw_device_with_io(
&self,
frame: &mut Frame,
region: Rect,
device: &Device,
ctx: RenderContext<'_>,
) {
let theme = ctx.theme;
if region.height == 0 {
return;
}
let pct = device.used_pct();
let colour = self.colour(pct, theme);
let muted = Style::default().fg(theme.muted);
let track = Style::default().fg(theme.track);
let gradient = &ctx.gradients.cpu;
let graph_rows = region.height.saturating_sub(3);
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Length(u16::from(region.height >= 2)), Constraint::Length(u16::from(region.height >= 3)), Constraint::Length(graph_rows.div_ceil(2)), Constraint::Length(graph_rows / 2), ])
.split(region);
frame.render_widget(
Paragraph::new(crate::grid::assemble(
figures(device, colour, theme),
rows[0].width,
)),
rows[0],
);
if rows[1].height > 0 {
frame.render_widget(
Paragraph::new(Line::from(meter_line(
pct,
rows[1].width,
colour,
theme.track,
))),
rows[1],
);
}
if rows[2].height > 0 {
let text = Style::default().fg(theme.text);
frame.render_widget(
Paragraph::new(crate::grid::assemble(
vec![
vec![
Span::styled("↓ ", muted),
Span::styled(rate(device.read_rate), text),
],
vec![
Span::styled(" ↑ ", muted),
Span::styled(rate(device.write_rate), text),
],
],
rows[2].width,
)),
rows[2],
);
}
let scale = self.scale(&device.mount);
let Some((reads, writes)) = self.histories.get(&device.mount) else {
return;
};
if rows[4].height == 0 && rows[3].height > 0 {
let shown = usize::from(rows[3].width) * 2;
let skip = reads.len().min(writes.len()).saturating_sub(shown);
let both: Vec<u64> = reads
.iter()
.zip(writes.iter())
.skip(skip)
.map(|(r, w)| r.saturating_add(*w))
.collect();
BrailleGraph::new(&both, scale.saturating_mul(2), gradient)
.track_style(track)
.render(rows[3], frame.buffer_mut());
return;
}
for (data, rect) in [(reads, rows[3]), (writes, rows[4])] {
if rect.height > 0 {
BrailleGraph::of_history(data, scale, gradient)
.track_style(track)
.render(rect, frame.buffer_mut());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::testing::screen;
fn volume(mount: &str, fs: &str, total: u64, available: u64, read_only: bool) -> Volume {
Volume {
mount: mount.into(),
name: String::new(),
file_system: fs.into(),
total,
available,
read_only,
read_rate: 0,
write_rate: 0,
}
}
fn named(name: &str, volume: Volume) -> Volume {
Volume {
name: name.into(),
..volume
}
}
fn device(mount: &str, total: u64, available: u64) -> Device {
Device {
mount: mount.into(),
total,
available,
read_rate: 0,
write_rate: 0,
}
}
fn quiet() -> DiskConfig {
DiskConfig {
show_io: false,
..DiskConfig::default()
}
}
fn panel(devices: Vec<Device>) -> DiskPanel {
DiskPanel::with_devices(quiet(), devices)
}
#[test]
fn bytes_read_the_way_a_disk_is_sold() {
assert_eq!(human(1_995_165_736_960), "2.0 TB");
assert_eq!(human(1_637_809_513_970), "1.6 TB");
assert_eq!(human(357_356_222_990), "357 GB");
assert_eq!(human(61_203_144_704), "61 GB");
assert_eq!(human(8_267_366_400), "8.3 GB");
assert_eq!(human(512_000_000), "512 MB");
assert_eq!(human(4_096), "4 kB");
assert_eq!(human(0), "0 kB");
}
#[test]
fn a_rate_keeps_a_decimal_below_ten_of_its_unit() {
for (bytes, expected) in [
(0, "0 kB/s"),
(40, "0 kB/s"),
(50, "0.1 kB/s"),
(999, "1.0 kB/s"),
(1_999, "2.0 kB/s"),
(4_096, "4.1 kB/s"),
(9_949, "9.9 kB/s"),
(9_950, "10 kB/s"),
(327_000, "327 kB/s"),
(327_680, "328 kB/s"),
(999_499, "999 kB/s"),
(999_500, "1.0 MB/s"),
(1_400_000, "1.4 MB/s"),
(1_450_000, "1.5 MB/s"),
(2_500_000, "2.5 MB/s"),
(12_500_000, "13 MB/s"),
(52_428_800, "52 MB/s"),
(100_000_000, "100 MB/s"),
(999_600_000, "1.0 GB/s"),
(3_000_000_000, "3.0 GB/s"),
(u64::MAX, "18446744 TB/s"),
] {
assert_eq!(rate(bytes), expected, "{bytes} bytes a second");
}
}
#[test]
fn a_percentage_saturates_and_never_divides_by_zero() {
assert_eq!(percent(0, 0), 0);
assert_eq!(percent(1, 4), 25);
assert_eq!(percent(9, 4), 100);
assert_eq!(percent(u64::MAX, u64::MAX), 100);
assert_eq!(device("/", 0, 0).used_pct(), 0);
assert_eq!(
device("/", 100, 150).used(),
0,
"free past total reads as nothing used, not as underflow"
);
}
#[test]
fn the_volumes_of_one_container_are_one_device_named_by_its_root() {
let devices = group(vec![
named(
"Macintosh HD",
volume("/", "apfs", 1_995_165_736_960, 1_637_885_797_874, true),
),
named(
"Macintosh HD",
volume(
"/System/Volumes/Data",
"apfs",
1_995_165_736_960,
1_637_809_513_970,
false,
),
),
]);
assert_eq!(
devices,
vec![device("/", 1_995_165_736_960, 1_637_809_513_970)]
);
}
#[test]
fn two_disks_of_one_size_with_different_fill_stay_separate() {
let devices = group(vec![
volume("/", "ext4", 1_000_000_000_000, 400_000_000_000, false),
volume("/home", "ext4", 1_000_000_000_000, 900_000_000_000, false),
]);
assert_eq!(devices.len(), 2, "{devices:?}");
assert_eq!(devices[0].mount, "/", "root first");
assert_eq!(devices[1].mount, "/home");
}
#[test]
fn two_disks_of_one_size_and_nearly_one_fill_stay_separate() {
for (a, b) in [("Backup A", "Backup B"), ("", "")] {
let devices = group(vec![
named(
a,
volume(
"/Volumes/Backup A",
"apfs",
2_000_000_000_000,
1_990_000_000_000,
false,
),
),
named(
b,
volume(
"/Volumes/Backup B",
"apfs",
2_000_000_000_000,
1_975_000_000_000,
false,
),
),
]);
assert_eq!(devices.len(), 2, "named {a:?} and {b:?}: {devices:?}");
}
}
#[test]
fn small_disks_of_one_size_are_held_no_looser_than_a_percent() {
for name in ["", "NO NAME"] {
for (total, a, b) in [
(8_000_000_000, 4_000_000_000, 3_900_000_000),
(100_000_000, 99_000_000, 1_000_000),
] {
let devices = group(vec![
named(name, volume("/media/stick-a", "vfat", total, a, false)),
named(name, volume("/media/stick-b", "vfat", total, b, false)),
]);
assert_eq!(
devices.len(),
2,
"{total}-byte pair named {name:?}: {devices:?}"
);
}
}
}
#[test]
fn a_container_read_during_a_write_is_still_one_device() {
for total in [245_107_195_904, 494_384_795_648, 1_995_165_736_960] {
for drift in [1_793_984, 47_857_664, 260_132_864, 641_363_968] {
let free = total / 2;
let devices = group(vec![
named("Macintosh HD", volume("/", "apfs", total, free, true)),
named(
"Macintosh HD",
volume("/System/Volumes/Data", "apfs", total, free - drift, false),
),
]);
assert_eq!(
devices,
vec![device("/", total, free - drift)],
"a {total}-byte container {drift} bytes apart"
);
}
}
}
#[test]
fn two_disks_of_one_size_and_one_name_within_a_percent_are_one_row() {
let devices = group(vec![
named(
"Untitled",
volume(
"/Volumes/Untitled",
"apfs",
2_000_000_000_000,
1_990_000_000_000,
false,
),
),
named(
"Untitled",
volume(
"/Volumes/Untitled 1",
"apfs",
2_000_000_000_000,
1_975_000_000_000,
false,
),
),
]);
assert_eq!(
devices,
vec![device(
"/Volumes/Untitled",
2_000_000_000_000,
1_975_000_000_000
)]
);
}
#[test]
fn a_container_whose_halves_are_named_apart_still_folds_within_the_tight_line() {
for total in [245_107_195_904, 494_384_795_648, 1_995_165_736_960] {
for drift in [1_793_984, 47_857_664] {
let free = total / 2;
let devices = group(vec![
named("Macintosh HD", volume("/", "apfs", total, free, true)),
named(
"Data",
volume("/System/Volumes/Data", "apfs", total, free - drift, false),
),
]);
assert_eq!(
devices,
vec![device("/", total, free - drift)],
"a {total}-byte container named apart, {drift} bytes apart"
);
}
}
}
#[test]
fn what_cannot_fill_up_is_not_listed() {
let devices = group(vec![
volume("/", "ext4", 500_000_000_000, 200_000_000_000, false),
volume("/snap/core/1234", "squashfs", 60_000_000, 0, true),
volume("/media/dvd", "iso9660", 4_700_000_000, 0, true),
volume("/run", "tmpfs", 8_000_000_000, 7_900_000_000, false),
volume("/mnt/ro", "ext4", 100_000_000_000, 10_000_000_000, true),
volume("/mnt/empty", "ext4", 0, 0, false),
]);
assert_eq!(devices, vec![device("/", 500_000_000_000, 200_000_000_000)]);
}
#[test]
fn only_read_only_volumes_are_still_shown_rather_than_nothing() {
let devices = group(vec![volume(
"/",
"ext4",
500_000_000_000,
200_000_000_000,
true,
)]);
assert_eq!(devices.len(), 1);
}
#[test]
fn devices_are_listed_root_first_then_by_mount() {
let devices = group(vec![
volume(
"/Volumes/Time Machine",
"apfs",
4_000_000_000_000,
1_000_000_000_000,
false,
),
volume(
"/Volumes/T7",
"exfat",
2_000_000_000_000,
61_000_000_000,
false,
),
volume("/", "apfs", 1_995_165_736_960, 1_637_809_513_970, false),
]);
let mounts: Vec<&str> = devices.iter().map(|d| d.mount.as_str()).collect();
assert_eq!(mounts, ["/", "/Volumes/T7", "/Volumes/Time Machine"]);
let devices = group(vec![
volume("D:\\", "NTFS", 2_000_000_000_000, 1_000_000_000_000, false),
volume("C:\\", "NTFS", 1_000_000_000_000, 100_000_000_000, false),
]);
let mounts: Vec<&str> = devices.iter().map(|d| d.mount.as_str()).collect();
assert_eq!(mounts, ["C:\\", "D:\\"]);
}
#[test]
fn the_colour_changes_only_at_the_thresholds() {
let theme = crate::theme::Theme::default();
let p = panel(vec![]);
assert_eq!(p.colour(0, &theme), theme.accent);
assert_eq!(p.colour(79, &theme), theme.accent);
assert_eq!(p.colour(80, &theme), theme.warning);
assert_eq!(p.colour(94, &theme), theme.warning);
assert_eq!(p.colour(95, &theme), theme.error);
assert_eq!(p.colour(100, &theme), theme.error);
let off = DiskPanel::with_devices(
DiskConfig {
warn_above_pct: 0,
alert_above_pct: 0,
..DiskConfig::default()
},
vec![],
);
assert_eq!(off.colour(100, &theme), theme.accent, "zero disables");
}
#[test]
fn the_alert_names_the_fullest_volume_past_the_line() {
let p = panel(vec![
device("/", 1_000_000_000_000, 40_000_000_000),
device("/Volumes/T7", 2_000_000_000_000, 20_000_000_000),
]);
assert_eq!(
p.alert().map(|a| a.text),
Some("Disk /Volumes/T7 at 99% — 20 GB free".to_string())
);
assert!(
panel(vec![device("/", 1_000_000_000_000, 60_000_000_000)])
.alert()
.is_none(),
"94% is below the line"
);
assert!(
DiskPanel::with_devices(
DiskConfig {
alert_above_pct: 0,
..DiskConfig::default()
},
vec![device("/", 100, 0)],
)
.alert()
.is_none(),
"zero disables"
);
assert!(panel(vec![]).alert().is_none());
}
#[test]
fn the_border_carries_what_is_free_on_the_first_volume() {
assert_eq!(
DiskPanel::canned(DiskConfig::default())
.counter()
.as_deref(),
Some("1.6 TB free")
);
assert_eq!(panel(vec![]).counter(), None);
assert_eq!(panel(vec![]).max_height(), None);
assert_eq!(
DiskPanel::canned(quiet()).max_height(),
Some(FRAME_HEIGHT + 5),
"two devices: text, meter, blank, text, meter"
);
assert_eq!(
DiskPanel::canned(DiskConfig::default()).max_height(),
None,
"with the graphs on, more height is more history"
);
}
#[test]
fn each_device_is_figures_over_a_meter_and_never_a_fragment() {
let mut p = DiskPanel::canned(quiet());
let rows = screen(&mut p, 60, 5);
assert_eq!(
rows[0], "/ 17% USED 1.6 TB free of 2.0 TB",
"{rows:?}"
);
assert!(
rows[1].starts_with("■■■") && rows[1].chars().count() == 60,
"{rows:?}"
);
assert_eq!(rows[2], "", "a blank line between devices: {rows:?}");
assert_eq!(
rows[3], "/Volumes/T7 96% USED 61 GB free of 2.0 TB",
"{rows:?}"
);
assert!(rows[4].starts_with('■'), "{rows:?}");
let tight = screen(&mut p, 60, 4);
assert_eq!(
tight[2], "/Volumes/T7 96% USED 61 GB free of 2.0 TB",
"no blank when there is no room: {tight:?}"
);
let tighter = screen(&mut p, 60, 2);
assert!(
tighter[1].starts_with("/Volumes/T7"),
"one row each: {tighter:?}"
);
for width in 1..=40u16 {
for height in 1..=6u16 {
for row in screen(&mut p, width, height) {
let t = row.trim();
for bad in ["fre", "of 2", "of 2.", "USE", "1.6 T", "61 G", " 1", " 9"] {
assert!(!t.ends_with(bad), "fragment {t:?} at {width}x{height}");
}
assert!(
!t.contains("17") || t.contains("17%"),
"a figure without its unit: {t:?} at {width}x{height}"
);
}
}
}
}
#[test]
fn a_machine_with_nothing_readable_says_so_and_before_the_first_read_says_it_is_reading() {
let mut none = panel(vec![]);
let rows = screen(&mut none, 44, 3).join("\n");
assert!(rows.contains("No disks readable"), "{rows}");
assert!(!rows.contains('■'), "no meter for nothing: {rows}");
let mut waiting = panel(vec![]);
waiting.cache = None;
let rows = screen(&mut waiting, 44, 3).join("\n");
assert!(rows.contains("Reading disks"), "{rows}");
}
#[test]
fn with_io_each_device_is_figures_meter_readout_and_two_graphs() {
let mut p = DiskPanel::canned(DiskConfig::default());
let rows = screen(&mut p, 60, 10);
assert_eq!(
rows[0], "/ 17% USED 1.6 TB free of 2.0 TB",
"{rows:?}"
);
assert!(rows[1].starts_with('■'), "{rows:?}");
assert_eq!(rows[2], "↓ 0 kB/s ↑ 328 kB/s", "{rows:?}");
assert!(
rows[3].starts_with('⣀') && rows[4].starts_with('⣀'),
"two graphs: {rows:?}"
);
assert!(rows[5].starts_with("/Volumes/T7"), "{rows:?}");
assert_eq!(rows[7], "↓ 52 MB/s ↑ 0 kB/s", "{rows:?}");
for width in 1..=40u16 {
for height in 1..=12u16 {
for row in screen(&mut p, width, height) {
let t = row.trim();
for bad in ["B/", "kB", "MB", "↑", "↓", "fre", "USE"] {
assert!(!t.ends_with(bad), "fragment {t:?} at {width}x{height}");
}
}
}
}
}
#[test]
fn the_rates_are_stated_in_the_units_of_the_capacity_beside_them() {
let mut busy = device("/", 1_000_000_000_000, 400_000_000_000);
busy.read_rate = 100_000_000;
busy.write_rate = 4_096;
let mut p = DiskPanel::with_devices(DiskConfig::default(), vec![busy]);
let rows = screen(&mut p, 60, 5);
assert_eq!(
rows[0], "/ 60% USED 400 GB free of 1.0 TB",
"{rows:?}"
);
assert_eq!(rows[2], "↓ 100 MB/s ↑ 4.1 kB/s", "{rows:?}");
}
#[test]
fn a_wide_panel_keeps_enough_history_to_fill_its_graphs() {
let mut p = DiskPanel::with_devices(DiskConfig::default(), vec![device("/", 100, 50)]);
screen(&mut p, 100, 8);
for _ in 0..300 {
p.take(vec![device("/", 100, 50)]);
}
assert_eq!(p.histories["/"].0.len(), 200);
assert_eq!(p.histories["/"].1.len(), 200);
}
#[test]
fn the_one_row_graph_sums_the_newest_samples() {
let idle = "⣀".repeat(10);
for (old, new, busy_now) in [(100_000_000, 0, false), (0, 100_000_000, true)] {
let mut p = DiskPanel::with_devices(DiskConfig::default(), vec![]);
for i in 0..100 {
let mut d = device("/", 100, 50);
d.write_rate = if i < 80 { old } else { new };
p.take(vec![d]);
}
assert_eq!(p.histories["/"].1.len(), 100, "longer than 2 × 10 cells");
let rows = screen(&mut p, 10, 4);
assert!(
rows[2].starts_with('↓'),
"one row left for graphs: {rows:?}"
);
if busy_now {
assert!(!rows[3].contains('⣀'), "busy at the end: {rows:?}");
} else {
assert_eq!(rows[3], idle, "idle at the end: {rows:?}");
}
}
}
#[test]
fn each_reading_feeds_the_histories_and_a_gone_device_is_forgotten() {
let mut p = DiskPanel::canned(DiskConfig::default());
assert_eq!(p.histories["/"].1.len(), 1);
let mut root = device("/", 100, 50);
root.read_rate = 7;
p.take(vec![root]);
assert_eq!(
p.histories["/"].0.iter().copied().collect::<Vec<_>>(),
[0, 7]
);
assert!(
!p.histories.contains_key("/Volumes/T7"),
"gone: {:?}",
p.histories.keys()
);
}
#[test]
fn the_scale_is_the_devices_peak_floored_at_a_megabyte() {
let p = DiskPanel::canned(DiskConfig::default());
assert_eq!(p.scale("/"), SCALE_FLOOR, "328 kB/s is under the floor");
assert_eq!(p.scale("/Volumes/T7"), 52_428_800);
assert_eq!(p.scale("/nowhere"), SCALE_FLOOR);
}
#[test]
fn i_toggles_the_graphs_and_is_documented() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut p = DiskPanel::canned(DiskConfig::default());
assert!(p.config.show_io);
let outcome = p.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert_eq!(outcome, crate::panel::KeyOutcome::Consumed);
assert!(!p.config.show_io);
assert!(p.bindings().iter().any(|b| b.key == "i"));
assert_eq!(
p.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)),
crate::panel::KeyOutcome::Ignored,
"every other key stays global"
);
}
#[test]
fn rows_per_device_gives_way_from_the_blank_line_down() {
assert_eq!(rows_per_device(5, 2), 3);
assert_eq!(rows_per_device(4, 2), 2);
assert_eq!(rows_per_device(3, 2), 1);
assert_eq!(
rows_per_device(2, 1),
3,
"one device needs two rows for three-spacing"
);
assert_eq!(rows_per_device(1, 1), 1);
assert_eq!(rows_per_device(0, 0), 1);
}
}