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