monitrs_core/model/storage.rs
1//! Filesystem capacity and block-device throughput.
2//!
3//! §7.3 and §26 both insist these are *different metrics*. A filesystem that is
4//! 95% full is not busy, and a device saturated at 100% utilization may sit on a
5//! nearly empty filesystem. They are therefore separate types, and no code path
6//! can accidentally render both as one unlabelled percentage.
7
8use crate::model::MetricState;
9use crate::units::{Percent, Rate};
10
11/// What kind of filesystem a mount point is backed by.
12///
13/// Used by the removable/virtual filter in §7.3, not for styling.
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize))]
16#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
17pub enum FilesystemKind {
18 /// A local block device.
19 Physical,
20 /// A local device that can be unplugged.
21 Removable,
22 /// A network mount such as NFS or SMB.
23 ///
24 /// Capacity reads on these can block for seconds, which is why filesystem
25 /// capacity lives in the medium tier rather than the fast one (§8.6).
26 Network,
27 /// A kernel pseudo-filesystem such as `tmpfs`, `devfs`, or `overlay`.
28 Virtual,
29 /// Not classifiable from the available information.
30 #[default]
31 Unknown,
32}
33
34impl FilesystemKind {
35 /// Whether this mount is hidden by default in the Storage screen.
36 #[must_use]
37 pub const fn hidden_by_default(self) -> bool {
38 matches!(self, Self::Virtual)
39 }
40}
41
42/// Inode occupancy of one filesystem.
43///
44/// A separate type from the byte figures because it answers a different question,
45/// and a classic operational surprise: a filesystem can refuse a `create` with
46/// `ENOSPC` while `df` shows plenty of free space, because what ran out was the
47/// inode table. Nothing else in this model can express that.
48///
49/// The fields are private so that the two invariants a reader relies on hold by
50/// construction: `total` is never zero — a filesystem with no inode table reports
51/// [`MetricState::Unsupported`] instead, never `0 of 0` (§4) — and `free` never
52/// exceeds `total`, so [`InodeUsage::used`] cannot underflow into a huge number.
53#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54#[cfg_attr(feature = "serde", derive(serde::Serialize))]
55pub struct InodeUsage {
56 total: u64,
57 free: u64,
58}
59
60impl InodeUsage {
61 /// The state a `statfs`/`statvfs` `(f_files, f_ffree)` pair describes.
62 ///
63 /// A zero `total` is what a filesystem with no fixed inode table reports, and
64 /// many do: it is [`MetricState::Unsupported`], because "this filesystem has
65 /// no inode limit" and "0 inodes exist" are opposite claims and only the first
66 /// is true (§4, §26).
67 #[must_use]
68 pub const fn from_counts(total: u64, free: u64) -> MetricState<Self> {
69 if total == 0 {
70 return MetricState::Unsupported;
71 }
72 MetricState::Available(Self {
73 total,
74 // A kernel that reports more free inodes than it has is reporting
75 // something we cannot interpret; clamping keeps `used` meaningful
76 // rather than wrapping it round to near `u64::MAX`.
77 free: if free > total { total } else { free },
78 })
79 }
80
81 /// Size of the inode table.
82 #[must_use]
83 pub const fn total(self) -> u64 {
84 self.total
85 }
86
87 /// Inodes still allocatable.
88 #[must_use]
89 pub const fn free(self) -> u64 {
90 self.free
91 }
92
93 /// Inodes in use: one per file, directory, symlink, and device node.
94 #[must_use]
95 pub const fn used(self) -> u64 {
96 self.total.saturating_sub(self.free)
97 }
98
99 /// Share of the inode table in use.
100 ///
101 /// Infallible, unlike the byte-capacity percentage, because [`Self::from_counts`]
102 /// has already rejected the zero-total case. The fallback exists only because
103 /// [`Percent::ratio`] cannot see that invariant, and it is the pessimistic
104 /// reading on purpose: a table whose size somehow could not be divided by is
105 /// reported as full, never as empty, since "empty" is the reassuring answer and
106 /// an uninterpretable count is not a reassuring situation.
107 #[must_use]
108 pub fn usage(self) -> Percent {
109 Percent::ratio(self.used(), self.total).unwrap_or(Percent::FULL)
110 }
111}
112
113/// Capacity of one mounted filesystem.
114///
115/// Contains no throughput fields at all; that is [`DiskSnapshot`]'s job.
116#[derive(Clone, Debug, PartialEq)]
117#[cfg_attr(feature = "serde", derive(serde::Serialize))]
118pub struct FilesystemSnapshot {
119 /// Where it is mounted.
120 pub mount_point: Box<str>,
121 /// The backing device, where the platform maps it.
122 pub device: Option<Box<str>>,
123 /// Filesystem type, e.g. `apfs`, `ext4`, `overlay`.
124 pub fs_type: Option<Box<str>>,
125 /// Total capacity.
126 pub total_bytes: u64,
127 /// Space available to the current user.
128 ///
129 /// Usually smaller than `total - used` because of reserved blocks.
130 pub available_bytes: MetricState<u64>,
131 /// Space in use.
132 pub used_bytes: MetricState<u64>,
133 /// Share of capacity used. Never mixed with device utilization (§7.3).
134 pub usage: MetricState<Percent>,
135 /// Inode occupancy, where the filesystem has an inode table to report.
136 ///
137 /// A *medium*-tier read like the byte capacity, and from the same `statfs`
138 /// call — `sysinfo` does not expose `f_files`, so it is the native layers that
139 /// fill this in and the baseline that leaves it [`MetricState::Unsupported`].
140 pub inodes: MetricState<InodeUsage>,
141 /// How the mount is classified.
142 pub kind: FilesystemKind,
143 /// Whether the mount is read-only.
144 pub read_only: bool,
145}
146
147impl FilesystemSnapshot {
148 /// Share of the inode table in use, carrying the inode read's own availability.
149 ///
150 /// The percentage a display wants: a refused or absent inode count produces a
151 /// state and never a number, and a retained count produces a percentage that is
152 /// marked stale exactly as the count was (§4).
153 #[must_use]
154 pub fn inode_usage(&self) -> MetricState<Percent> {
155 self.inodes.as_ref().map(|inodes| inodes.usage())
156 }
157}
158
159/// Cumulative device counters, kept alongside rates so the Inspect screen can
160/// show totals as well as throughput.
161#[derive(Clone, Copy, Debug, Default, PartialEq)]
162#[cfg_attr(feature = "serde", derive(serde::Serialize))]
163pub struct DiskTotals {
164 /// Bytes read since boot.
165 pub read_bytes: u64,
166 /// Bytes written since boot.
167 pub write_bytes: u64,
168}
169
170/// Throughput of one block device.
171///
172/// Contains no capacity fields at all; that is [`FilesystemSnapshot`]'s job.
173#[derive(Clone, Debug, PartialEq)]
174#[cfg_attr(feature = "serde", derive(serde::Serialize))]
175pub struct DiskSnapshot {
176 /// Kernel device name, e.g. `nvme0n1` or `disk0`.
177 pub device: Box<str>,
178 /// Hardware model, where reported.
179 pub model: Option<Box<str>>,
180 /// Read throughput.
181 pub read: MetricState<Rate>,
182 /// Write throughput.
183 pub write: MetricState<Rate>,
184 /// Read operations per second.
185 pub read_ops: MetricState<Rate>,
186 /// Write operations per second.
187 pub write_ops: MetricState<Rate>,
188 /// Fraction of wall time the device had at least one request in flight.
189 ///
190 /// §7.3 limits this to platforms where it is *semantically correct*: it is
191 /// derived from `/proc/diskstats` field 10 on Linux and is
192 /// [`MetricState::Unsupported`] elsewhere, because a queue-depth-based
193 /// approximation on an NVMe device is misleading rather than merely
194 /// imprecise.
195 pub busy: MetricState<Percent>,
196 /// Average in-flight request count.
197 pub queue_length: MetricState<f32>,
198 /// Cumulative counters.
199 pub totals: MetricState<DiskTotals>,
200 /// Mount points backed by this device, where the mapping is available.
201 ///
202 /// §8.6 puts this expensive mapping in the on-demand tier, so it is often
203 /// empty in a fast-tier snapshot.
204 pub mount_points: Vec<Box<str>>,
205}
206
207impl DiskSnapshot {
208 /// A device whose counters exist but whose rates need a second sample.
209 #[must_use]
210 pub fn warming_up(device: Box<str>) -> Self {
211 Self {
212 device,
213 model: None,
214 read: MetricState::WarmingUp,
215 write: MetricState::WarmingUp,
216 read_ops: MetricState::WarmingUp,
217 write_ops: MetricState::WarmingUp,
218 busy: MetricState::WarmingUp,
219 queue_length: MetricState::WarmingUp,
220 totals: MetricState::WarmingUp,
221 mount_points: Vec::new(),
222 }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn virtual_filesystems_are_hidden_by_default_and_real_ones_are_not() {
232 assert!(FilesystemKind::Virtual.hidden_by_default());
233 for kind in [
234 FilesystemKind::Physical,
235 FilesystemKind::Removable,
236 FilesystemKind::Network,
237 FilesystemKind::Unknown,
238 ] {
239 assert!(!kind.hidden_by_default(), "{kind:?}");
240 }
241 }
242
243 #[test]
244 fn a_warming_up_device_reports_no_throughput_and_no_busy_percentage() {
245 let disk = DiskSnapshot::warming_up("nvme0n1".into());
246 assert!(disk.read.fresh().is_none());
247 assert!(disk.busy.fresh().is_none());
248 assert!(disk.mount_points.is_empty());
249 }
250
251 /// The type system is what keeps §7.3 honest: neither struct can express the
252 /// other's metric, so no widget can conflate them by accident.
253 #[test]
254 fn capacity_and_throughput_live_in_separate_types() {
255 fn assert_fields<T>(_: &T) {}
256 let fs = FilesystemSnapshot {
257 mount_point: "/".into(),
258 device: Some("disk3s1s1".into()),
259 fs_type: Some("apfs".into()),
260 total_bytes: 494_384_795_648,
261 available_bytes: MetricState::Available(120_000_000_000),
262 used_bytes: MetricState::Available(374_384_795_648),
263 usage: Percent::ratio(374_384_795_648, 494_384_795_648)
264 .map_or(MetricState::Unsupported, MetricState::Available),
265 inodes: InodeUsage::from_counts(4_882_812_499, 4_395_698_642),
266 kind: FilesystemKind::Physical,
267 read_only: false,
268 };
269 assert_fields(&fs);
270 assert!(fs.usage.fresh().is_some());
271 let disk = DiskSnapshot::warming_up("disk0".into());
272 assert_fields(&disk);
273 }
274
275 #[test]
276 fn a_filesystem_with_no_inode_table_is_unsupported_and_never_zero_of_zero() {
277 // The property §4 exists for. `f_files == 0` is what a filesystem without a
278 // fixed inode table reports, and rendering it as `0 of 0` would say the
279 // table is exhausted — the opposite of the truth.
280 assert_eq!(InodeUsage::from_counts(0, 0), MetricState::Unsupported);
281 assert_eq!(InodeUsage::from_counts(0, 12), MetricState::Unsupported);
282 }
283
284 #[test]
285 fn inode_usage_is_a_share_of_the_table_and_cannot_underflow() {
286 let inodes = InodeUsage::from_counts(1_000, 250)
287 .fresh()
288 .copied()
289 .expect("a thousand inodes is a table");
290 assert_eq!(inodes.used(), 750);
291 assert_eq!(inodes.free(), 250);
292 assert_eq!(inodes.usage(), Percent::new(75.0).expect("finite"));
293
294 // More free than total cannot be interpreted, and must not wrap `used`
295 // round to near u64::MAX.
296 let nonsense = InodeUsage::from_counts(10, 99)
297 .fresh()
298 .copied()
299 .expect("the table size is still known");
300 assert_eq!(nonsense.used(), 0);
301 assert_eq!(nonsense.free(), 10);
302 }
303
304 #[test]
305 fn the_inode_percentage_carries_the_counts_availability() {
306 // §4: a refused count produces a state, never a number, and a stale count
307 // produces a percentage that is still marked stale.
308 let mut fs = FilesystemSnapshot {
309 mount_point: "/".into(),
310 device: None,
311 fs_type: None,
312 total_bytes: 1,
313 available_bytes: MetricState::Unsupported,
314 used_bytes: MetricState::Unsupported,
315 usage: MetricState::Unsupported,
316 inodes: MetricState::PermissionDenied,
317 kind: FilesystemKind::Physical,
318 read_only: false,
319 };
320 assert_eq!(fs.inode_usage(), MetricState::PermissionDenied);
321
322 fs.inodes = InodeUsage::from_counts(4, 1);
323 assert_eq!(
324 fs.inode_usage().fresh().map(|percent| percent.value()),
325 Some(75.0)
326 );
327
328 fs.inodes = fs.inodes.into_stale(core::time::Duration::from_secs(9));
329 assert!(fs.inode_usage().is_stale());
330 }
331}