Skip to main content

rigtop_core/
snapshot.rs

1//! Serializable snapshot of everything rigtop displays.
2//!
3//! A [`Snapshot`] is the single unit of data exchanged between a data source
4//! (local collectors, or a remote `rigtop --agent` over SSH) and a front-end
5//! (TUI or GUI). Every type here derives `Serialize`/`Deserialize` so that the
6//! same structure can be streamed over the wire unchanged.
7//!
8//! Conventions:
9//! - Sizes are in bytes, rates in bytes per second.
10//! - Temperatures are in degrees Celsius, power in watts, fan speed in RPM.
11//! - Percentages are in the range `0.0..=100.0` (per-core values are not summed).
12//! - Values that a platform or device cannot provide are `None` (or an empty
13//!   list) rather than zero, so that front-ends can hide them.
14//! - Every struct is `#[serde(default)]`: fields missing from the input take
15//!   their default, so a client can read snapshots from an older or newer
16//!   `rigtop --agent` as long as fields are only added.
17
18use serde::{Deserialize, Serialize};
19
20/// One full measurement of a host.
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22#[serde(default)]
23pub struct Snapshot {
24    /// Wall-clock time of the measurement, in milliseconds since the Unix epoch.
25    pub timestamp_ms: u64,
26    /// Static-ish information about the host.
27    pub host: HostInfo,
28    /// CPU load, frequency and temperature.
29    pub cpu: CpuStats,
30    /// Physical memory and swap.
31    pub memory: MemoryStats,
32    /// Discrete and integrated GPUs that could be queried.
33    pub gpus: Vec<GpuStats>,
34    /// Raw hardware sensor chips (temperatures and fans), grouped by chip.
35    pub sensors: Vec<SensorChip>,
36    /// Power draw summary.
37    pub power: PowerStats,
38    /// I/O activity per physical block device.
39    pub disks: Vec<DiskIo>,
40    /// Capacity of mounted filesystems.
41    pub filesystems: Vec<FilesystemUsage>,
42    /// Software RAID state (btrfs and mdadm).
43    pub raid: RaidStats,
44    /// SMART health per drive, from files written by a privileged timer.
45    pub smart: Vec<SmartDevice>,
46    /// Network interface traffic.
47    pub networks: Vec<NetworkStats>,
48    /// Process list. May be truncated by the source (see [`Snapshot::truncate_processes`]).
49    pub processes: Vec<ProcessInfo>,
50    /// Total number of processes on the host, even if `processes` was truncated.
51    pub process_count: usize,
52}
53
54impl Snapshot {
55    /// Keeps only the `limit` processes with the highest CPU usage.
56    ///
57    /// Used to reduce the payload when a host is shown in an overview rather
58    /// than in detail. `process_count` keeps the original total.
59    pub fn truncate_processes(&mut self, limit: usize) {
60        if self.processes.len() <= limit {
61            return;
62        }
63        // Partial sort is enough: only the top `limit` entries matter.
64        self.processes
65            .select_nth_unstable_by(limit, |a, b| b.cpu_percent.total_cmp(&a.cpu_percent));
66        self.processes.truncate(limit);
67        self.processes
68            .sort_unstable_by(|a, b| b.cpu_percent.total_cmp(&a.cpu_percent));
69    }
70}
71
72/// Host identification and uptime.
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74#[serde(default)]
75pub struct HostInfo {
76    /// Host name as reported by the OS.
77    pub hostname: String,
78    /// Human readable OS name and version (e.g. "Linux 26.04 Ubuntu").
79    pub os: String,
80    /// Kernel version string.
81    pub kernel: String,
82    /// Seconds since boot.
83    pub uptime_secs: u64,
84}
85
86/// CPU-wide and per-core statistics.
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88#[serde(default)]
89pub struct CpuStats {
90    /// Brand string of the first CPU (e.g. "AMD Ryzen 9 9950X3D 16-Core Processor").
91    pub model: String,
92    /// Usage across all logical cores.
93    pub total_percent: f32,
94    /// Usage of each logical core, in OS order.
95    pub per_core_percent: Vec<f32>,
96    /// Current frequency of each logical core in MHz.
97    pub per_core_mhz: Vec<u64>,
98    /// 1, 5 and 15 minute load averages; `None` on platforms without them
99    /// (Windows).
100    pub load_average: Option<[f64; 3]>,
101    /// CPU temperature readings, e.g. `Tctl`, `Tccd1`, `Tccd2` on AMD or
102    /// `Package id 0`, `Core 0`.. on Intel.
103    pub temperatures: Vec<Temperature>,
104}
105
106/// Physical memory and swap usage.
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108#[serde(default)]
109pub struct MemoryStats {
110    /// Installed (visible) memory.
111    pub total_bytes: u64,
112    /// Memory in use by processes (total minus available).
113    pub used_bytes: u64,
114    /// Memory that can be allocated without swapping.
115    pub available_bytes: u64,
116    /// Completely unused memory.
117    pub free_bytes: u64,
118    /// Page cache and reclaimable slab, where the platform reports it.
119    pub cached_bytes: Option<u64>,
120    /// Total swap space.
121    pub swap_total_bytes: u64,
122    /// Swap space in use.
123    pub swap_used_bytes: u64,
124}
125
126/// A single GPU.
127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
128#[serde(default)]
129pub struct GpuStats {
130    /// Index within its vendor API (NVML index for NVIDIA).
131    pub index: u32,
132    /// Vendor name ("NVIDIA", "AMD", ...).
133    pub vendor: String,
134    /// Marketing name of the device.
135    pub name: String,
136    /// Core utilization.
137    pub utilization_percent: Option<f32>,
138    /// Memory controller utilization.
139    pub memory_utilization_percent: Option<f32>,
140    /// VRAM in use.
141    pub memory_used_bytes: Option<u64>,
142    /// Total VRAM.
143    pub memory_total_bytes: Option<u64>,
144    /// Core temperature.
145    pub temperature_c: Option<f32>,
146    /// Current board power draw.
147    pub power_w: Option<f32>,
148    /// Enforced power limit.
149    pub power_limit_w: Option<f32>,
150    /// Fan speed as a percentage of the maximum (first fan).
151    pub fan_percent: Option<f32>,
152    /// Graphics clock in MHz.
153    pub clock_mhz: Option<u32>,
154    /// Processes currently using this GPU.
155    pub processes: Vec<GpuProcess>,
156}
157
158/// GPU usage attributed to one process.
159#[derive(Debug, Clone, Default, Serialize, Deserialize)]
160#[serde(default)]
161pub struct GpuProcess {
162    /// OS process id.
163    pub pid: u32,
164    /// VRAM used by the process, if the driver reports it.
165    pub memory_bytes: Option<u64>,
166    /// Streaming multiprocessor (core) utilization by the process.
167    pub sm_percent: Option<f32>,
168}
169
170/// A hardware monitoring chip (a Linux hwmon device) and its readings.
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172#[serde(default)]
173pub struct SensorChip {
174    /// Stable identifier, `"<driver name>:<device name>"`
175    /// (e.g. `"nct6686:nct6683.2592"`, `"nvme:nvme0"`).
176    ///
177    /// Unlike `hwmonN`, this survives reboots, so it is used as the key for
178    /// user-defined labels.
179    pub key: String,
180    /// Driver-provided chip name (e.g. `"k10temp"`, `"nvme"`, `"spd5118"`).
181    pub name: String,
182    /// Temperature channels.
183    pub temperatures: Vec<Temperature>,
184    /// Fan channels.
185    pub fans: Vec<Fan>,
186}
187
188/// One temperature channel.
189#[derive(Debug, Clone, Default, Serialize, Deserialize)]
190#[serde(default)]
191pub struct Temperature {
192    /// Stable key `"<chip key>/temp<N>"` used to look up user-defined names.
193    /// Empty for readings that do not come from a hwmon chip.
194    pub key: String,
195    /// Driver label (e.g. `"Tccd1"`), or a generic `"temp<N>"` when none exists.
196    pub label: String,
197    /// Current reading.
198    pub celsius: f32,
199    /// High threshold, if exposed.
200    pub max_celsius: Option<f32>,
201    /// Critical threshold, if exposed.
202    pub critical_celsius: Option<f32>,
203}
204
205/// One fan channel.
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207#[serde(default)]
208pub struct Fan {
209    /// Stable key `"<chip key>/fan<N>"` used to look up user-defined names.
210    pub key: String,
211    /// Driver label, or a generic `"fan<N>"` when none exists.
212    pub label: String,
213    /// Current speed (0 when stopped or not connected).
214    pub rpm: u32,
215}
216
217/// Power draw summary.
218///
219/// There is no portable way to measure whole-system power without external
220/// hardware, so the total is the sum of the CPU package and discrete GPUs,
221/// which dominate consumption on typical workstations.
222#[derive(Debug, Clone, Default, Serialize, Deserialize)]
223#[serde(default)]
224pub struct PowerStats {
225    /// CPU package power (RAPL on Linux). `None` before the second sample or
226    /// when RAPL is unavailable.
227    pub cpu_package_w: Option<f32>,
228    /// Sum of power reported by discrete GPUs.
229    pub gpus_w: Option<f32>,
230    /// Integrated GPU power (e.g. amdgpu PPT). Reported separately and *not*
231    /// added to the total, because it may already be part of the CPU package.
232    pub igpu_w: Option<f32>,
233    /// `cpu_package_w + gpus_w`, counting only the parts that are available.
234    pub total_w: Option<f32>,
235}
236
237/// I/O activity of a block device.
238#[derive(Debug, Clone, Default, Serialize, Deserialize)]
239#[serde(default)]
240pub struct DiskIo {
241    /// Kernel device name (e.g. `"nvme0n1"`, `"sda"`).
242    pub name: String,
243    /// Read throughput.
244    pub read_bytes_per_sec: f64,
245    /// Write throughput.
246    pub write_bytes_per_sec: f64,
247    /// Fraction of time the device had I/O in flight, where the platform
248    /// reports it (Linux).
249    pub busy_percent: Option<f32>,
250    /// Drive temperature (NVMe composite sensor), if available.
251    pub temperature_c: Option<f32>,
252}
253
254/// Capacity of one filesystem.
255///
256/// Filesystems mounted at several places (e.g. btrfs subvolumes) appear once,
257/// with all mount points listed.
258#[derive(Debug, Clone, Default, Serialize, Deserialize)]
259#[serde(default)]
260pub struct FilesystemUsage {
261    /// Source device (e.g. `"/dev/nvme1n1p1"`).
262    pub device: String,
263    /// Filesystem type (e.g. `"btrfs"`, `"ext4"`).
264    pub fs_type: String,
265    /// All mount points of this filesystem, shortest first.
266    pub mount_points: Vec<String>,
267    /// Total size.
268    pub total_bytes: u64,
269    /// Space available to unprivileged users.
270    pub available_bytes: u64,
271}
272
273/// Returns true if `device` (a disk such as `"sda"`, or an NVMe controller
274/// such as `"nvme0"`) is the drive behind the block device `disk`
275/// (`"sda"`, `"nvme0n1"`).
276///
277/// `"nvme1"` does not match `"nvme10n1"`: after the controller name only the
278/// namespace suffix (`n<N>`) may follow.
279pub fn device_matches_disk(device: &str, disk: &str) -> bool {
280    match disk.strip_prefix(device) {
281        Some("") => true,
282        Some(rest) => device.starts_with("nvme") && rest.starts_with('n'),
283        None => false,
284    }
285}
286
287/// SMART health of one drive, parsed from `smartctl -j -a` output.
288///
289/// Fields a drive or protocol does not report are `None` (NVMe drives have
290/// no sector reallocation counters, ATA drives no "percentage used").
291#[derive(Debug, Clone, Default, Serialize, Deserialize)]
292#[serde(default)]
293pub struct SmartDevice {
294    /// Kernel device name (`"nvme0"`, `"sda"`). See [`device_matches_disk`].
295    pub device: String,
296    /// Protocol reported by smartctl (`"NVMe"`, `"ATA"`, `"SCSI"`).
297    pub protocol: String,
298    /// Drive model.
299    pub model: String,
300    /// Overall health self-assessment; `None` if smartctl could not tell.
301    pub passed: Option<bool>,
302    /// Current drive temperature.
303    pub temperature_c: Option<f32>,
304    /// Accumulated power-on time.
305    pub power_on_hours: Option<u64>,
306    /// NVMe wear estimate (may exceed 100).
307    pub percentage_used: Option<u32>,
308    /// NVMe spare capacity left.
309    pub available_spare_percent: Option<u32>,
310    /// NVMe spare threshold below which the drive reports a warning.
311    pub available_spare_threshold_percent: Option<u32>,
312    /// NVMe critical warning bit field (0 when healthy).
313    pub critical_warning: Option<u32>,
314    /// NVMe unrecovered data integrity errors.
315    pub media_errors: Option<u64>,
316    /// ATA attribute 5 (Reallocated_Sector_Ct), raw value.
317    pub reallocated_sectors: Option<u64>,
318    /// ATA attribute 197 (Current_Pending_Sector), raw value.
319    pub pending_sectors: Option<u64>,
320    /// ATA attribute 198 (Offline_Uncorrectable), raw value.
321    pub offline_uncorrectable: Option<u64>,
322    /// Total host writes, where the drive reports them.
323    pub data_written_bytes: Option<u64>,
324    /// When smartctl ran, in milliseconds since the Unix epoch. Lets front-ends
325    /// flag stale results (e.g. the timer stopped running).
326    pub collected_at_ms: Option<u64>,
327    /// Error reported by smartctl (e.g. the device could not be opened).
328    pub error: Option<String>,
329}
330
331impl SmartDevice {
332    /// Returns true if anything in the SMART data calls for attention.
333    pub fn needs_attention(&self) -> bool {
334        let positive = |v: Option<u64>| v.is_some_and(|v| v > 0);
335        let spare_low = match (
336            self.available_spare_percent,
337            self.available_spare_threshold_percent,
338        ) {
339            (Some(spare), Some(threshold)) => spare < threshold,
340            _ => false,
341        };
342        self.passed == Some(false)
343            || self.error.is_some()
344            || self.critical_warning.is_some_and(|w| w != 0)
345            || positive(self.media_errors)
346            || positive(self.reallocated_sectors)
347            || positive(self.pending_sectors)
348            || positive(self.offline_uncorrectable)
349            || self.percentage_used.is_some_and(|p| p >= 100)
350            || spare_low
351    }
352}
353
354/// Software RAID state.
355#[derive(Debug, Clone, Default, Serialize, Deserialize)]
356#[serde(default)]
357pub struct RaidStats {
358    /// Mounted btrfs filesystems.
359    pub btrfs: Vec<BtrfsFilesystem>,
360    /// Linux md (mdadm) arrays.
361    pub md: Vec<MdArray>,
362}
363
364/// A btrfs filesystem as seen in `/sys/fs/btrfs/<fsid>`.
365#[derive(Debug, Clone, Default, Serialize, Deserialize)]
366#[serde(default)]
367pub struct BtrfsFilesystem {
368    /// Filesystem UUID.
369    pub fsid: String,
370    /// Filesystem label (may be empty).
371    pub label: String,
372    /// Mount points found in the mount table for this filesystem.
373    pub mount_points: Vec<String>,
374    /// Member block devices (e.g. `"nvme0n1"`, `"nvme1n1p1"`).
375    pub devices: Vec<String>,
376    /// Per-device error counters, keyed by btrfs device id.
377    ///
378    /// The kernel does not expose the devid-to-name mapping in sysfs, so this
379    /// list is not aligned with `devices`.
380    pub device_stats: Vec<BtrfsDeviceStats>,
381    /// Allocation per block group type (data, metadata, system).
382    pub allocations: Vec<BtrfsAllocation>,
383    /// Result of the most recent scrub, if it is known. Error counters in
384    /// `device_stats` only grow when data is read, so the scrub time tells
385    /// when all data was last verified.
386    pub scrub: Option<BtrfsScrub>,
387}
388
389/// Outcome of the last btrfs scrub (summed over devices).
390#[derive(Debug, Clone, Default, Serialize, Deserialize)]
391#[serde(default)]
392pub struct BtrfsScrub {
393    /// Start time, in milliseconds since the Unix epoch.
394    pub started_at_ms: u64,
395    /// Run time in seconds.
396    pub duration_secs: u64,
397    /// `"finished"`, `"canceled"` or `"unfinished"` (running or interrupted).
398    pub state: String,
399    /// Bytes verified.
400    pub bytes_scrubbed: u64,
401    /// Read, checksum, verify and superblock errors found.
402    pub errors: u64,
403    /// Errors that were repaired from a good copy.
404    pub corrected: u64,
405    /// Errors that could not be repaired.
406    pub uncorrectable: u64,
407}
408
409impl BtrfsFilesystem {
410    /// Returns true if any member is missing or has a non-zero error counter.
411    pub fn has_problems(&self) -> bool {
412        self.device_stats
413            .iter()
414            .any(|d| d.missing || d.total_errors() > 0)
415    }
416}
417
418/// Error counters of one btrfs member device.
419#[derive(Debug, Clone, Default, Serialize, Deserialize)]
420#[serde(default)]
421pub struct BtrfsDeviceStats {
422    /// btrfs device id (1-based).
423    pub devid: u64,
424    /// Whether the device is missing from the filesystem.
425    pub missing: bool,
426    /// Write I/O errors.
427    pub write_errs: u64,
428    /// Read I/O errors.
429    pub read_errs: u64,
430    /// Flush errors.
431    pub flush_errs: u64,
432    /// Checksum mismatches.
433    pub corruption_errs: u64,
434    /// Generation mismatches (stale data).
435    pub generation_errs: u64,
436}
437
438impl BtrfsDeviceStats {
439    /// Sum of all error counters.
440    pub fn total_errors(&self) -> u64 {
441        self.write_errs
442            + self.read_errs
443            + self.flush_errs
444            + self.corruption_errs
445            + self.generation_errs
446    }
447}
448
449/// Space allocation of one btrfs block group type and RAID profile.
450#[derive(Debug, Clone, Default, Serialize, Deserialize)]
451#[serde(default)]
452pub struct BtrfsAllocation {
453    /// Block group type: `"data"`, `"metadata"` or `"system"`.
454    pub kind: String,
455    /// RAID profile (e.g. `"single"`, `"raid0"`, `"raid1c3"`).
456    pub profile: String,
457    /// Space allocated to this profile (logical size).
458    pub total_bytes: u64,
459    /// Space used within the allocation (logical size).
460    pub used_bytes: u64,
461}
462
463/// One Linux md array parsed from `/proc/mdstat`.
464#[derive(Debug, Clone, Default, Serialize, Deserialize)]
465#[serde(default)]
466pub struct MdArray {
467    /// Array name (e.g. `"md0"`).
468    pub name: String,
469    /// Whether the array is active.
470    pub active: bool,
471    /// Whether the array is read-only (`active (read-only)` / `(auto-read-only)`).
472    pub read_only: bool,
473    /// RAID level (e.g. `"raid1"`); empty for inactive arrays.
474    pub level: String,
475    /// Member devices.
476    pub members: Vec<MdMember>,
477    /// Number of devices the array should have (`[n/m]` -> n).
478    pub expected_devices: Option<u32>,
479    /// Number of devices currently in sync (`[n/m]` -> m).
480    pub working_devices: Option<u32>,
481    /// Ongoing resync/recovery/reshape/check, if any.
482    pub sync: Option<MdSync>,
483}
484
485impl MdArray {
486    /// Returns true if the array runs with fewer devices than expected or has a failed member.
487    pub fn is_degraded(&self) -> bool {
488        let short = match (self.expected_devices, self.working_devices) {
489            (Some(expected), Some(working)) => working < expected,
490            _ => false,
491        };
492        short || self.members.iter().any(|m| m.faulty)
493    }
494}
495
496/// A member device of an md array.
497#[derive(Debug, Clone, Default, Serialize, Deserialize)]
498#[serde(default)]
499pub struct MdMember {
500    /// Device name (e.g. `"sda1"`).
501    pub name: String,
502    /// Role number within the array.
503    pub role: u32,
504    /// Marked faulty `(F)`.
505    pub faulty: bool,
506    /// Spare device `(S)`.
507    pub spare: bool,
508}
509
510/// Progress of an md background operation.
511#[derive(Debug, Clone, Default, Serialize, Deserialize)]
512#[serde(default)]
513pub struct MdSync {
514    /// Operation name: `"resync"`, `"recovery"`, `"reshape"` or `"check"`.
515    pub action: String,
516    /// Completion percentage.
517    pub percent: f32,
518    /// Estimated minutes until completion.
519    pub finish_minutes: Option<f32>,
520    /// Current speed in KiB/s.
521    pub speed_kib_per_sec: Option<u64>,
522}
523
524/// Traffic of one network interface.
525#[derive(Debug, Clone, Default, Serialize, Deserialize)]
526#[serde(default)]
527pub struct NetworkStats {
528    /// Interface name (e.g. `"enp9s0"`).
529    pub name: String,
530    /// Receive throughput.
531    pub rx_bytes_per_sec: f64,
532    /// Transmit throughput.
533    pub tx_bytes_per_sec: f64,
534    /// Bytes received since the interface came up.
535    pub rx_total_bytes: u64,
536    /// Bytes transmitted since the interface came up.
537    pub tx_total_bytes: u64,
538    /// Negotiated link speed in Mbit/s (wired links), if known.
539    pub link_speed_mbps: Option<u32>,
540    /// Whether the link is up, if the OS reports it.
541    pub link_up: Option<bool>,
542    /// Wi-Fi link details, for wireless interfaces.
543    pub wireless: Option<WirelessLink>,
544}
545
546/// State of a Wi-Fi link.
547#[derive(Debug, Clone, Default, Serialize, Deserialize)]
548#[serde(default)]
549pub struct WirelessLink {
550    /// Current receive bit rate in Mbit/s.
551    pub rx_bitrate_mbps: Option<f32>,
552    /// Current transmit bit rate in Mbit/s.
553    pub tx_bitrate_mbps: Option<f32>,
554    /// Signal strength in dBm.
555    pub signal_dbm: Option<i32>,
556}
557
558/// One process.
559#[derive(Debug, Clone, Default, Serialize, Deserialize)]
560#[serde(default)]
561pub struct ProcessInfo {
562    /// Process id.
563    pub pid: u32,
564    /// Parent process id.
565    pub parent_pid: Option<u32>,
566    /// Short name (executable name).
567    pub name: String,
568    /// Full command line joined by spaces.
569    pub command: String,
570    /// Owner user name (or numeric uid when it cannot be resolved).
571    pub user: String,
572    /// Scheduling state (e.g. "Run", "Sleep", "Zombie").
573    pub state: String,
574    /// CPU usage; 100 means one full core, so multi-threaded processes may exceed 100.
575    pub cpu_percent: f32,
576    /// Resident memory.
577    pub memory_bytes: u64,
578    /// Number of threads, where known.
579    pub threads: Option<u32>,
580    /// Disk read throughput.
581    pub read_bytes_per_sec: f64,
582    /// Disk write throughput.
583    pub write_bytes_per_sec: f64,
584    /// Seconds since the process started.
585    pub run_time_secs: u64,
586    /// Kernel thread (e.g. `kworker`), which front-ends may hide.
587    pub kernel_thread: bool,
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    fn process(pid: u32, cpu: f32) -> ProcessInfo {
595        ProcessInfo {
596            pid,
597            cpu_percent: cpu,
598            ..Default::default()
599        }
600    }
601
602    #[test]
603    fn truncate_keeps_top_cpu_processes_in_order() {
604        let mut snapshot = Snapshot {
605            processes: vec![
606                process(1, 5.0),
607                process(2, 50.0),
608                process(3, 0.0),
609                process(4, 20.0),
610            ],
611            process_count: 4,
612            ..Default::default()
613        };
614        snapshot.truncate_processes(2);
615        let pids: Vec<u32> = snapshot.processes.iter().map(|p| p.pid).collect();
616        assert_eq!(pids, vec![2, 4]);
617        // The total is preserved for display ("2 of 4 processes").
618        assert_eq!(snapshot.process_count, 4);
619    }
620
621    #[test]
622    fn snapshot_round_trips_through_json() {
623        let snapshot = Snapshot {
624            timestamp_ms: 42,
625            processes: vec![process(7, 1.5)],
626            ..Default::default()
627        };
628        let json = serde_json::to_string(&snapshot).unwrap();
629        let back: Snapshot = serde_json::from_str(&json).unwrap();
630        assert_eq!(back.timestamp_ms, 42);
631        assert_eq!(back.processes[0].pid, 7);
632    }
633
634    #[test]
635    fn device_matching_handles_nvme_namespaces() {
636        assert!(device_matches_disk("sda", "sda"));
637        assert!(device_matches_disk("nvme0", "nvme0n1"));
638        assert!(device_matches_disk("nvme1", "nvme1n1"));
639        assert!(!device_matches_disk("nvme1", "nvme10n1"));
640        assert!(!device_matches_disk("sd", "sda"));
641        assert!(!device_matches_disk("sda", "sdb"));
642    }
643
644    #[test]
645    fn smart_attention_flags() {
646        let healthy = SmartDevice {
647            passed: Some(true),
648            media_errors: Some(0),
649            available_spare_percent: Some(100),
650            available_spare_threshold_percent: Some(10),
651            ..Default::default()
652        };
653        assert!(!healthy.needs_attention());
654        let worn = SmartDevice {
655            available_spare_percent: Some(5),
656            ..healthy.clone()
657        };
658        assert!(worn.needs_attention());
659        let reallocated = SmartDevice {
660            reallocated_sectors: Some(8),
661            ..healthy
662        };
663        assert!(reallocated.needs_attention());
664    }
665
666    #[test]
667    fn md_array_degraded_detection() {
668        let mut array = MdArray {
669            expected_devices: Some(2),
670            working_devices: Some(2),
671            ..Default::default()
672        };
673        assert!(!array.is_degraded());
674        array.working_devices = Some(1);
675        assert!(array.is_degraded());
676    }
677}