gander-core 0.6.4

Platform data collection and process model for gander. No UI dependency.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
//! Pure `/proc` parsers.
//!
//! Every function here takes `&[u8]` rather than a path so that the whole parser
//! layer is testable against committed fixtures on any OS
//! (`docs/DESIGN.md` §10).

use crate::row::{CommBuf, Row, RowFlags};
use crate::system::{
    CpuTimes, DiskCounters, MemInfo, NetCounters, PsiLine, PsiResource, SwapInfo, ZramDevice,
};

/// Parse `/proc/<pid>/stat`.
///
/// Returns `None` when the buffer is malformed. Note the trap: `comm` may
/// contain spaces, `(` and `)`, so we anchor on the **last** `)`.
pub fn parse_stat(buf: &[u8]) -> Option<(u32, CommBuf, StatFields)> {
    let l = memchr::memchr(b'(', buf)?;
    let r = memchr::memrchr(b')', buf)?;
    if r <= l {
        return None;
    }

    let pid_part = &buf[..l];
    let pid: u32 = std::str::from_utf8(pid_part).ok()?.trim().parse().ok()?;

    let mut comm = CommBuf::new();
    // comm is arbitrary bytes in principle; lossy-convert rather than fail.
    let comm_raw = String::from_utf8_lossy(&buf[l + 1..r]);
    comm.try_push_str(&comm_raw).ok()?;

    let rest = buf.get(r + 1..)?;
    let mut it = rest
        .split(|b| *b == b' ' || *b == b'\n')
        .filter(|t| !t.is_empty());

    let mut f: [&[u8]; 22] = [b""; 22];
    for slot in f.iter_mut() {
        *slot = it.next()?;
    }

    let num = |s: &[u8]| -> Option<u64> {
        // Fast path for unsigned decimal.
        let mut v: u64 = 0;
        for &c in s {
            if !c.is_ascii_digit() {
                return None;
            }
            v = v.wrapping_mul(10).wrapping_add((c - b'0') as u64);
        }
        Some(v)
    };
    let signed = |s: &[u8]| -> Option<i64> {
        let (neg, s) = match s.first() {
            Some(b'-') => (true, &s[1..]),
            Some(b'+') => (false, &s[1..]),
            _ => (false, s),
        };
        let v = num(s)? as i64;
        Some(if neg { -v } else { v })
    };

    let fields = StatFields {
        state: *f[0].first()?,
        ppid: num(f[1])? as u32,
        pgrp: num(f[2])? as u32,
        session: num(f[3])? as u32,
        tty_nr: signed(f[4])?,
        tpgid: signed(f[5])?,
        min_flt: num(f[7])?,
        maj_flt: num(f[9])?,
        utime: num(f[11])?,
        stime: num(f[12])?,
        priority: signed(f[15])? as i8,
        nice: signed(f[16])? as i8,
        nlwp: num(f[17])? as i32,
        starttime: num(f[19])?,
        vsize: num(f[20])?,
        rss_pages: num(f[21])?,
    };

    Some((pid, comm, fields))
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct StatFields {
    pub state: u8,
    pub ppid: u32,
    pub pgrp: u32,
    pub session: u32,
    pub tty_nr: i64,
    /// Field 8 of `stat`: the foreground process group of `tty_nr`.
    ///
    /// `-1` when the terminal has no foreground job, which is a value and not an
    /// absence — the renderer has to show it as a number, not as `-`.
    pub tpgid: i64,
    pub min_flt: u64,
    pub maj_flt: u64,
    pub utime: u64,
    pub stime: u64,
    pub priority: i8,
    pub nice: i8,
    pub nlwp: i32,
    pub starttime: u64,
    pub vsize: u64,
    pub rss_pages: u64,
}

impl StatFields {
    pub fn cpu_ticks(&self) -> u64 {
        self.utime.saturating_add(self.stime)
    }
}

/// Apply parsed stat fields to a row, converting pages to bytes.
pub fn fill_row(row: &mut Row, st: &StatFields, page_size: u64) {
    row.state = st.state;
    row.ppid = st.ppid;
    row.pgrp = st.pgrp;
    row.session = st.session;
    row.tty_nr = st.tty_nr;
    row.tpgid = st.tpgid;
    row.min_flt = st.min_flt;
    row.maj_flt = st.maj_flt;
    row.priority = st.priority;
    row.nice = st.nice;
    row.nlwp = st.nlwp;
    row.starttime = st.starttime;
    row.virt = st.vsize;
    row.res = st.rss_pages.saturating_mul(page_size);
    row.time_ticks = st.cpu_ticks();
}

/// Parse `/proc/<pid>/status` for the effective UID and thread count.
///
/// `status` is expensive relative to `stat`; this is only called when the
/// columns that need it are active, or when a row is new.
pub fn parse_status_uid(buf: &[u8]) -> Option<u32> {
    for line in buf.split(|b| *b == b'\n') {
        if let Some(rest) = line.strip_prefix(b"Uid:") {
            let mut it = rest.split(|b| *b == b'\t' || *b == b' ');
            it.find(|t| !t.is_empty())?; // real uid
            let effective = it.find(|t| !t.is_empty())?;
            return std::str::from_utf8(effective).ok()?.trim().parse().ok();
        }
    }
    None
}

/// Parse `/proc/meminfo` into `MemInfo`/`SwapInfo` (values in bytes).
pub fn parse_meminfo(buf: &[u8], mem: &mut MemInfo, swap: &mut SwapInfo) {
    for line in buf.split(|b| *b == b'\n') {
        let Some(colon) = memchr::memchr(b':', line) else {
            continue;
        };
        let key = &line[..colon];
        let rest = &line[colon + 1..];
        let Some(val) = first_number(rest) else {
            continue;
        };
        // /proc/meminfo is in kB.
        let bytes = val.saturating_mul(1024);
        match key {
            b"MemTotal" => mem.total = bytes,
            b"MemFree" => mem.free = bytes,
            b"MemAvailable" => mem.available = bytes,
            b"Buffers" => mem.buffers = bytes,
            b"Cached" => mem.cached = bytes,
            b"SReclaimable" => mem.sreclaimable = bytes,
            b"Shmem" => mem.shmem = bytes,
            // zswap: bytes currently held, and bytes ever swapped to it. Both
            // are already counted in `used`, so they feed a meter of their own
            // rather than the memory bar.
            b"Zswap" => mem.zswap = bytes,
            b"Zswapped" => mem.zswapped = bytes,
            // Huge pages are counted in *pages*, not kB. Multiplying these by
            // 1024 the way every other field is multiplied would report a
            // 512-page reservation as 512 kB instead of 1 GiB.
            b"HugePages_Total" => mem.huge_pages_total = val,
            b"HugePages_Free" => mem.huge_pages_free = val,
            b"HugePages_Rsvd" => mem.huge_pages_rsvd = val,
            b"HugePages_Surp" => mem.huge_pages_surplus = val,
            b"Hugepagesize" => mem.huge_page_size = bytes,
            b"SwapTotal" => swap.total = bytes,
            b"SwapFree" => swap.free = bytes,
            _ => {}
        }
    }
    mem.finish_linux();
    swap.used = swap.total.saturating_sub(swap.free);
}

fn first_number(buf: &[u8]) -> Option<u64> {
    let start = buf.iter().position(|c| c.is_ascii_digit())?;
    let mut v: u64 = 0;
    for &c in &buf[start..] {
        if !c.is_ascii_digit() {
            break;
        }
        v = v.wrapping_mul(10).wrapping_add((c - b'0') as u64);
    }
    Some(v)
}

/// Parse `/proc/stat` into `[aggregate, cpu0, cpu1, ...]`.
pub fn parse_proc_stat(buf: &[u8]) -> Vec<CpuTimes> {
    let mut out = Vec::new();
    for line in buf.split(|b| *b == b'\n') {
        let Some(rest) = line.strip_prefix(b"cpu") else {
            continue;
        };
        // The aggregate line is "cpu  100 ...": the digits after `cpu` are the
        // first value. A per-core line is "cpu0 50 ...", where the leading `0`
        // is the core index and must be skipped.
        let rest = match rest.first() {
            Some(c) if c.is_ascii_digit() => match memchr::memchr(b' ', rest) {
                Some(sp) => &rest[sp..],
                None => continue,
            },
            _ => rest,
        };

        let mut vals = [0u64; 10];
        let mut n = 0;
        for tok in rest.split(|b| *b == b' ').filter(|t| !t.is_empty()) {
            if n == vals.len() {
                break;
            }
            vals[n] = first_number(tok).unwrap_or(0);
            n += 1;
        }
        if n == 0 {
            continue;
        }
        out.push(CpuTimes {
            user: vals[0],
            nice: vals[1],
            system: vals[2],
            idle: vals[3],
            iowait: vals[4],
            irq: vals[5],
            softirq: vals[6],
            steal: vals[7],
            guest: vals[8],
            guest_nice: vals[9],
        });
    }
    out
}

/// Parse `/proc/loadavg` : `0.52 0.58 0.59 1/1234 5678`.
pub fn parse_loadavg(buf: &[u8]) -> [f64; 3] {
    let s = String::from_utf8_lossy(buf);
    let mut it = s.split_whitespace();
    let a = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0);
    let b = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0);
    let c = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0);
    [a, b, c]
}

/// Classify a row as a kernel thread or a userland thread.
///
/// Kernel threads have an empty `/proc/<pid>/cmdline` and no `exe`; threads have
/// a `Tgid != Pid`.
pub fn classify(row: &mut Row, tgid: u32, cmdline_empty: bool, has_exe: bool) {
    if tgid != row.pid {
        row.flags = row.flags.insert(RowFlags::USERLAND_THREAD);
    } else if cmdline_empty && !has_exe {
        row.flags = row.flags.insert(RowFlags::KERNEL_THREAD);
    }
}

/// Parse `/proc/net/dev` into cumulative host counters.
///
/// The file has two header lines, then one line per interface:
///
/// ```text
///  face |bytes    packets errs drop fifo frame compressed multicast|bytes ...
///     lo: 1234 12 0 0 0 0 0 0 1234 12 0 0 0 0 0 0
///   eth0: 5678 34 0 0 0 0 0 0 5678 34 0 0 0 0 0 0
/// ```
///
/// Note that the interface name and the first counter share a colon-separated
/// field, so the split has to happen before the whitespace tokenisation, not
/// after it. Loopback is skipped: see [`crate::system::NetCounters`].
pub fn parse_net_dev(buf: &[u8]) -> NetCounters {
    let mut out = NetCounters::default();
    for line in buf.split(|b| *b == b'\n') {
        // The two header lines contain no colon followed by digits.
        let Some(colon) = memchr::memchr(b':', line) else {
            continue;
        };
        let name = trim_ascii(&line[..colon]);
        if name.is_empty() {
            continue;
        }
        // Loopback never reaches a wire, and counting it would double every
        // local byte (once received, once transmitted).
        if name == b"lo" {
            continue;
        }
        let mut it = line[colon + 1..]
            .split(|b| b.is_ascii_whitespace())
            .filter(|t| !t.is_empty());
        // Fields, in order: rx bytes, packets, errs, drop, fifo, frame,
        // compressed, multicast, then the same eight for tx. Only bytes matter,
        // so skip the seven rx counters that follow rx bytes.
        let Some(rx) = it.next().and_then(parse_u64) else {
            continue;
        };
        let tx = it.nth(7).and_then(parse_u64).unwrap_or(0);
        out.rx_bytes = out.rx_bytes.saturating_add(rx);
        out.tx_bytes = out.tx_bytes.saturating_add(tx);
    }
    out
}

/// Parse `/proc/diskstats` into cumulative host counters.
///
/// Fields 3 and 6 (1-based) are sectors read and written; a sector is always
/// 512 bytes on Linux regardless of the device's physical sector size. Only the
/// whole-disk entries are counted: a partition's counters are already included
/// in its parent disk's, so summing both would double every byte.
///
/// A device counts as a whole disk when `/sys/block/<name>` exists. Without
/// sysfs (a fixture tree, a container with a masked `/sys`) fall back to a
/// name heuristic, which is what htop's `diskio` does too.
pub fn parse_diskstats(buf: &[u8], is_whole_disk: impl Fn(&[u8]) -> bool) -> DiskCounters {
    const SECTOR: u64 = 512;
    let mut out = DiskCounters::default();
    for line in buf.split(|b| *b == b'\n') {
        let mut it = line
            .split(|b| b.is_ascii_whitespace())
            .filter(|t| !t.is_empty());
        // major, minor, name, then 11+ counters.
        let (_major, _minor) = (it.next(), it.next());
        let Some(name) = it.next() else { continue };
        if !is_whole_disk(name) {
            continue;
        }
        // reads completed, reads merged, sectors read, ms reading,
        // writes completed, writes merged, sectors written
        let Some(read_sectors) = it.nth(2).and_then(parse_u64) else {
            continue;
        };
        // `nth` returns the element it lands on, so from field 3 this skips
        // fields 4 and 5 and lands on 6 — one short. Sectors *written* is field
        // 7, so three steps, not two.
        let write_sectors = it.nth(3).and_then(parse_u64).unwrap_or(0);
        out.read_bytes = out
            .read_bytes
            .saturating_add(read_sectors.saturating_mul(SECTOR));
        out.write_bytes = out
            .write_bytes
            .saturating_add(write_sectors.saturating_mul(SECTOR));
    }
    out
}

/// Whether a `/proc/diskstats` device name looks like a whole disk.
///
/// The authoritative answer is "does `/sys/block/<name>` exist"; the scan layer
/// checks that and only falls back here when sysfs is unavailable (a fixture
/// tree, or a container with a masked `/sys`).
///
/// Partitions must be excluded, not filtered later: a partition's counters are
/// already included in its parent disk's, so counting both doubles every byte.
/// That is also why `dm-*` is excluded — a device-mapper device wraps the same
/// I/O as the disk below it.
pub fn looks_like_whole_disk(name: &[u8]) -> bool {
    // Pseudo-devices: their I/O is either not disk I/O or already accounted for
    // by something else. `zram` in particular would double-count swap.
    for prefix in [
        b"loop".as_slice(),
        b"ram".as_slice(),
        b"zram".as_slice(),
        b"dm-".as_slice(),
        b"sr".as_slice(),
        b"nbd".as_slice(),
        b"fd".as_slice(),
    ] {
        if name.starts_with(prefix) {
            return false;
        }
    }

    // `nvme0n1` is a whole disk, `nvme0n1p1` a partition: the `p` after the
    // namespace number is the separator. Same shape for `mmcblk0` / `mmcblk0p1`.
    for prefix in [b"nvme".as_slice(), b"mmcblk".as_slice()] {
        if let Some(rest) = name.strip_prefix(prefix) {
            return !rest.contains(&b'p');
        }
    }

    // Everything else: `sda` / `vda` / `hda` are disks, a trailing digit means a
    // partition (`sda1`).
    !name.last().is_some_and(u8::is_ascii_digit)
}

/// Trim ASCII whitespace from both ends of a byte slice.
fn trim_ascii(mut b: &[u8]) -> &[u8] {
    while let Some((first, rest)) = b.split_first() {
        if first.is_ascii_whitespace() {
            b = rest;
        } else {
            break;
        }
    }
    while let Some((last, rest)) = b.split_last() {
        if last.is_ascii_whitespace() {
            b = rest;
        } else {
            break;
        }
    }
    b
}

fn parse_u64(b: &[u8]) -> Option<u64> {
    if b.is_empty() {
        return None;
    }
    let mut v: u64 = 0;
    for &c in b {
        if !c.is_ascii_digit() {
            return None;
        }
        v = v.wrapping_mul(10).wrapping_add((c - b'0') as u64);
    }
    Some(v)
}

/// One process's GPU counters, accumulated over its open DRM file descriptors.
///
/// A process can hold several DRM fds (render node, card node), so this is a sum
/// rather than one device's figure. That is the right shape for a process table:
/// the question a row answers is "how much GPU is this program using", not "which
/// node is it using".
#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
pub struct GpuUsage {
    /// Cumulative nanoseconds of GPU time attributed to this process.
    pub gpu_ns: u64,
    /// Cumulative nanoseconds the GPU was *busy* while this process had work
    /// queued. The percentage is `drm-engine-*` divided by this, which is why the
    /// engine figure alone is not a percentage.
    pub busy_ns: u64,
    /// Cumulative nanoseconds of memory cycles, on drivers that report it.
    pub mem_ns: u64,
}

impl GpuUsage {
    /// GPU utilisation in percent, `0.0..=100.0`.
    ///
    /// Returns `None` when the driver reports engine time but no `drm-busy`, in
    /// which case no percentage can be computed — inventing one from the sample
    /// interval would be a different quantity wearing the same label.
    pub fn percent(&self) -> Option<f32> {
        if self.busy_ns == 0 {
            return None;
        }
        Some((self.gpu_ns as f64 / self.busy_ns as f64 * 100.0).clamp(0.0, 100.0) as f32)
    }
}

/// Parse one `fdinfo` file for `drm-*` keys.
///
/// # The format
///
/// ```text
/// pos:    0
/// flags:    02100002
/// drm-driver:    nvidia-drm
/// drm-pdev:    0000:01:00.0
/// drm-engine-gfx:    12345678 ns
/// drm-engine-mem:    12345678 ns
/// drm-busy:    12345678 ns
/// ```
///
/// Keys are driver-specific: `drm-engine-<class>` names vary (`gfx`, `render`,
/// `video`, `compute`, …), and `drm-busy` / `drm-memory-<region>` only appear on
/// some drivers. Engine classes are therefore summed by prefix rather than by name,
/// and a missing `drm-busy` leaves [`GpuUsage::percent`] returning `None` instead
/// of guessing.
///
/// Returns `None` when the file has no `drm-` key at all, which is the common case:
/// most descriptors are not DRM.
pub fn parse_fdinfo_drm(buf: &[u8]) -> Option<GpuUsage> {
    let mut out = GpuUsage::default();
    let mut seen_drm = false;

    for line in buf.split(|b| *b == b'\n') {
        let Some(colon) = memchr::memchr(b':', line) else {
            continue;
        };
        let key = trim_ascii(&line[..colon]);
        if !key.starts_with(b"drm-") {
            continue;
        }
        seen_drm = true;
        let rest = &line[colon + 1..];
        // Values are `<number> ns` (`drm-engine-*`, `drm-busy`, `drm-memory-*`)
        // or a free-form string (`drm-driver`, `drm-pdev`). Only the numeric ones
        // matter here, so the first number in the value is the whole answer.
        let Some(value) = first_number_in(rest) else {
            continue;
        };
        if key.starts_with(b"drm-engine-") {
            // Every engine class contributes: a process that only uses video
            // decode should not read as "no GPU".
            out.gpu_ns = out.gpu_ns.saturating_add(value);
        } else if key == b"drm-busy" {
            out.busy_ns = out.busy_ns.saturating_add(value);
        } else if key.starts_with(b"drm-memory-") {
            // Memory *cycles*, not bytes: the unit is still ns.
            out.mem_ns = out.mem_ns.saturating_add(value);
        }
    }

    if seen_drm {
        Some(out)
    } else {
        None
    }
}

/// The first run of decimal digits anywhere in `buf`.
///
/// `fdinfo` values are tab-separated (`\t123 ns`), and `memchr` on the colon
/// leaves the separator in place, so the digits are not necessarily at the start.
/// Deliberately separate from [`first_number`], which requires the digits to begin
/// the slice and is used where a leading separator would be a parse error.
fn first_number_in(buf: &[u8]) -> Option<u64> {
    let start = buf.iter().position(|c| c.is_ascii_digit())?;
    let mut v: u64 = 0;
    for &c in &buf[start..] {
        if !c.is_ascii_digit() {
            break;
        }
        v = v.wrapping_mul(10).wrapping_add((c - b'0') as u64);
    }
    Some(v)
}

/// Parse one line of `/proc/pressure/<resource>`.
///
/// ```text
/// some avg10=0.00 avg60=0.00 avg300=0.00 total=0
/// ```
///
/// Parsed by key rather than by position: the kernel has added keys to this file
/// before, and a positional parser would silently shift every value when it does
/// again.
pub fn parse_psi_line(buf: &[u8]) -> Option<PsiLine> {
    let mut line = PsiLine::default();
    let mut seen = false;
    for tok in buf.split(|b| *b == b' ').filter(|t| !t.is_empty()) {
        let Some(eq) = memchr::memchr(b'=', tok) else {
            continue;
        };
        let (key, value) = (&tok[..eq], &tok[eq + 1..]);
        let value = trim_ascii(value);
        match key {
            b"avg10" => {
                line.avg10 = parse_f32(value)?;
                seen = true;
            }
            b"avg60" => line.avg60 = parse_f32(value)?,
            b"avg300" => line.avg300 = parse_f32(value)?,
            b"total" => line.total_us = parse_u64(value)?,
            _ => {}
        }
    }
    if seen {
        Some(line)
    } else {
        None
    }
}

/// Parse a whole `/proc/pressure/<resource>` file.
pub fn parse_psi(buf: &[u8]) -> PsiResource {
    let mut out = PsiResource {
        present: true,
        ..Default::default()
    };
    for line in buf.split(|b| *b == b'\n') {
        let line = trim_ascii(line);
        if let Some(rest) = line.strip_prefix(b"some ") {
            if let Some(v) = parse_psi_line(rest) {
                out.some = v;
            }
        } else if let Some(rest) = line.strip_prefix(b"full ") {
            if let Some(v) = parse_psi_line(rest) {
                out.full = v;
                out.has_full = true;
            }
        }
    }
    out
}

/// Parse a `/proc/pressure`-style decimal (`0.00`, `12.34`) into an `f32`.
///
/// `str::parse` on the UTF-8 slice is the simple route and this file is not on
/// the per-process path — PSI is read once per sample, not once per process.
fn parse_f32(b: &[u8]) -> Option<f32> {
    std::str::from_utf8(b).ok()?.trim().parse().ok()
}

/// Parse one `/sys/block/zram<N>/mm_stat` line.
///
/// The columns are, in order:
///
/// ```text
/// orig_data_size compr_data_size mem_used_total mem_limit mem_used_max
/// same_pages pages_compacted huge_pages huge_pages_since
/// ```
///
/// All in bytes except the page counts. Only the first three are needed, and the
/// rest are read positionally *after* them, which is safe because the kernel
/// only ever appends to this line.
pub fn parse_zram_mm_stat(buf: &[u8], name: &str) -> Option<ZramDevice> {
    let mut it = buf.split(|b| *b == b' ' || *b == b'\n').filter(|t| !t.is_empty());
    let orig = parse_u64(trim_ascii(it.next()?))?;
    let compr = parse_u64(trim_ascii(it.next()?))?;
    let mem_used = parse_u64(trim_ascii(it.next()?))?;
    Some(ZramDevice {
        name: name.to_string(),
        orig_data_size: orig,
        compr_data_size: compr,
        mem_used_total: mem_used,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    const STAT_TRICKY: &[u8] =
        b"1234 (my ) we(ird comm) S 1 1234 1234 0 -1 4194560 100 0 5 0 12 34 0 0 20 0 3 0 987654 1048576 256 18446744073709551615\n";

    #[test]
    fn stat_handles_parens_in_comm() {
        let (pid, comm, f) = parse_stat(STAT_TRICKY).expect("must parse");
        assert_eq!(pid, 1234);
        assert_eq!(&*comm, "my ) we(ird comm");
        assert_eq!(f.state, b'S');
        assert_eq!(f.ppid, 1);
        assert_eq!(f.min_flt, 100);
        assert_eq!(f.maj_flt, 5);
        assert_eq!(f.utime, 12);
        assert_eq!(f.stime, 34);
        assert_eq!(f.cpu_ticks(), 46);
        assert_eq!(f.nlwp, 3);
        assert_eq!(f.starttime, 987_654);
        assert_eq!(f.vsize, 1_048_576);
        assert_eq!(f.rss_pages, 256);
    }

    #[test]
    fn stat_rejects_truncated() {
        assert!(parse_stat(b"1234 (bash) S 1 2").is_none());
        assert!(parse_stat(b"garbage").is_none());
    }

    const MEMINFO: &[u8] = b"MemTotal:       16000000 kB\nMemFree:         1000000 kB\nMemAvailable:    8000000 kB\nBuffers:          100000 kB\nCached:          2000000 kB\nSReclaimable:     500000 kB\nShmem:            200000 kB\nSwapTotal:       2000000 kB\nSwapFree:        1500000 kB\n";

    #[test]
    fn meminfo_matches_free_accounting() {
        let mut mem = MemInfo::default();
        let mut swap = SwapInfo::default();
        parse_meminfo(MEMINFO, &mut mem, &mut swap);
        assert_eq!(mem.total, 16_000_000 * 1024);
        assert_eq!(swap.used, 500_000 * 1024);

        // used = total - free - cached - sreclaimable - buffers
        //      = 16e6 - 1e6 - 2e6 - 5e5 - 1e5 = 12.4e6 kB
        assert_eq!(mem.used, 12_400_000 * 1024);
        // MemAvailable is reported verbatim; making it complement `used` would
        // clamp it to 3.6e6 kB and understate reclaimable cache.
        assert_eq!(mem.available, 8_000_000 * 1024);
        assert!(mem.is_consistent(), "{mem:?}");
        // used + available exceeds total on Linux; that overlap is the
        // reclaimable cache counted by both.
        assert!(!mem.is_exactly_partitioned());
        assert!(mem.used + mem.available > mem.total);
    }

    const PROC_STAT: &[u8] = b"cpu  100 10 50 1000 20 5 5 0 0 0\ncpu0 50 5 25 500 10 2 3 0 0 0\ncpu1 50 5 25 500 10 3 2 0 0 0\nintr 12345 0 0\nctxt 999\n";

    #[test]
    fn proc_stat_parsing() {
        let v = parse_proc_stat(PROC_STAT);
        assert_eq!(v.len(), 3, "aggregate plus two cores");
        assert_eq!(v[0].user, 100);
        assert_eq!(v[1].softirq, 3);
        assert_eq!(v[2].irq, 3);
        assert_eq!(v[0].total(), 100 + 10 + 50 + 1000 + 20 + 5 + 5);
    }

    #[test]
    fn loadavg_parsing() {
        assert_eq!(
            parse_loadavg(b"0.52 0.58 0.59 1/1234 5678\n"),
            [0.52, 0.58, 0.59]
        );
    }

    #[test]
    fn status_uid() {
        let buf = b"Name:\tbash\nUid:\t1000\t1000\t1000\t1000\nGid:\t1000\t1000\t1000\t1000\n";
        assert_eq!(parse_status_uid(buf), Some(1000));
    }

    /// A real `/proc/net/dev` with the two header lines intact, because the
    /// header is where the colon-splitting trap lives: the first data field is
    /// glued to the interface name (`eth0:1234`).
    const NET_DEV: &[u8] = b"Inter-|   Receive                                                |  Transmit\n face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed\n    lo:     100       1    0    0    0     0          0         0      100       1    0    0    0     0       0          0\n  eth0:    5678      34    0    0    0     0          0         0     9101      56    0    0    0     0       0          0\n wlan0:    1000       9    0    0    0     0          0         0     2000      10    0    0    0     0       0          0\n";

    #[test]
    fn net_dev_sums_non_loopback_interfaces() {
        let n = parse_net_dev(NET_DEV);
        assert_eq!(n.rx_bytes, 5678 + 1000);
        assert_eq!(n.tx_bytes, 9101 + 2000);
    }

    #[test]
    fn net_dev_without_loopback_still_works() {
        let buf = b"h1\nh2\n  eth0: 10 1 0 0 0 0 0 0 20 1 0 0 0 0 0 0\n";
        let n = parse_net_dev(buf);
        assert_eq!(n.rx_bytes, 10);
        assert_eq!(n.tx_bytes, 20);
    }

    #[test]
    fn net_dev_garbage_is_zero_not_a_panic() {
        assert_eq!(parse_net_dev(b""), NetCounters::default());
        assert_eq!(parse_net_dev(b"no colons here\n"), NetCounters::default());
    }

    /// Real `/proc/diskstats` lines: a whole disk, one of its partitions, and a
    /// loop device. Only the disk may be counted.
    const DISKSTATS: &[u8] = concat!(
        "   8       0 sda 1000 0 8000 100 500 0 4000 50 0 100 150\n",
        "   8       1 sda1 900 0 7000 90 400 0 3000 40 0 90 130\n",
        " 259       0 nvme0n1 200 0 1600 20 100 0 800 10 0 20 30\n",
        " 259       1 nvme0n1p1 150 0 1200 15 80 0 600 8 0 15 22\n",
        "   7       0 loop0 5 0 40 1 0 0 0 0 0 1 1\n",
    )
    .as_bytes();

    #[test]
    fn diskstats_counts_whole_disks_only() {
        let d = parse_diskstats(DISKSTATS, looks_like_whole_disk);
        // sda: 8000 sectors read, 4000 written. nvme0n1: 1600 / 800.
        assert_eq!(d.read_bytes, (8000 + 1600) * 512);
        assert_eq!(d.write_bytes, (4000 + 800) * 512);
    }

    #[test]
    fn diskstats_write_field_is_seven_not_six() {
        // A single disk where read and write sectors are deliberately different:
        // picking field 6 instead of 7 would return 2222 here.
        let buf = b"   8       0 sda 1 0 1111 0 1 0 2222 0 0 0 0\n";
        let d = parse_diskstats(buf, |_| true);
        assert_eq!(d.read_bytes, 1111 * 512);
        assert_eq!(d.write_bytes, 2222 * 512, "sectors written is field 7");
    }

    #[test]
    fn whole_disk_names() {
        for disk in ["sda", "vda", "hdb", "nvme0n1", "mmcblk0", "xvda"] {
            assert!(looks_like_whole_disk(disk.as_bytes()), "{disk} is a disk");
        }
        for part in ["sda1", "vda12", "nvme0n1p1", "mmcblk0p2"] {
            assert!(!looks_like_whole_disk(part.as_bytes()), "{part} is a partition");
        }
        for pseudo in ["loop0", "ram0", "zram0", "dm-0", "sr0"] {
            assert!(!looks_like_whole_disk(pseudo.as_bytes()), "{pseudo} is skipped");
        }
    }

    // --- M3: PSI -----------------------------------------------------------

    const PSI_IO: &[u8] = b"some avg10=12.50 avg60=8.25 avg300=4.10 total=98765432\nfull avg10=40.50 avg60=30.25 avg300=20.10 total=45678901\n";

    #[test]
    fn psi_parses_both_lines() {
        let r = parse_psi(PSI_IO);
        assert!(r.present);
        assert!(r.has_full);
        assert_eq!(r.some.avg10, 12.5);
        assert_eq!(r.some.avg60, 8.25);
        assert_eq!(r.some.total_us, 98_765_432);
        assert_eq!(r.full.avg10, 40.5);
        assert_eq!(r.full.total_us, 45_678_901);
    }

    /// A file with only a `some` line must leave `has_full` false rather than
    /// fabricating a zero, which would read as "nothing ever stalls completely".
    ///
    /// Real kernels do emit `full` for every resource — including `cpu` — so this
    /// is about the *parser's* contract with the file, not about a per-resource
    /// table of assumptions.
    #[test]
    fn psi_without_a_full_line_reports_has_full_false() {
        let r = parse_psi(b"some avg10=1.25 avg60=0.75 avg300=0.50 total=12345678\n");
        assert!(r.present);
        assert!(!r.has_full, "no full line was present in the input");
        assert_eq!(r.some.avg10, 1.25);
        assert_eq!(r.full, PsiLine::default());
    }

    /// And when the line *is* there it is parsed, for every resource.
    #[test]
    fn psi_full_line_is_parsed_when_present() {
        let r = parse_psi(
            b"some avg10=1.25 avg60=0.75 avg300=0.50 total=10\nfull avg10=0.50 avg60=0.25 avg300=0.10 total=5\n",
        );
        assert!(r.has_full);
        assert_eq!(r.full.avg10, 0.5);
        assert_eq!(r.full.total_us, 5);
    }

    /// Values are matched by key, not by position, so a kernel that appends a key
    /// cannot shift the ones we read.
    #[test]
    fn psi_keys_are_positional_independent() {
        let mut shuffled = parse_psi(b"some total=5 avg10=1.00\n");
        assert_eq!(shuffled.some.avg10, 1.0);
        assert_eq!(shuffled.some.total_us, 5);
        assert!(!shuffled.has_full);
        shuffled = parse_psi(b"");
        assert!(shuffled.present, "an empty file was still readable");
        assert_eq!(shuffled.some, PsiLine::default());
    }

    #[test]
    fn psi_rejects_a_line_with_no_avg() {
        assert!(parse_psi_line(b"total=5").is_none());
        assert!(parse_psi_line(b"").is_none());
    }

    // --- M3: zram ----------------------------------------------------------

    #[test]
    fn zram_mm_stat_parses_the_first_three_columns() {
        let line = b"1073741824 268435456 268435456 0 268435456 1000 5 0 0\n";
        let d = parse_zram_mm_stat(line, "zram0").expect("must parse");
        assert_eq!(d.name, "zram0");
        assert_eq!(d.orig_data_size, 1_073_741_824);
        assert_eq!(d.compr_data_size, 268_435_456);
        assert_eq!(d.mem_used_total, 268_435_456);
        // 1 GiB down to 256 MiB is a 4:1 ratio.
        assert!((d.ratio() - 4.0).abs() < 0.001);
    }

    #[test]
    fn zram_rejects_a_truncated_line() {
        assert!(parse_zram_mm_stat(b"", "zram0").is_none());
        assert!(parse_zram_mm_stat(b"123 456", "zram0").is_none());
    }

    /// An empty device must not claim an infinite compression ratio.
    #[test]
    fn an_empty_zram_device_reports_ratio_one() {
        let d = parse_zram_mm_stat(b"0 0 0 0 0 0 0 0 0\n", "zram0").unwrap();
        assert_eq!(d.ratio(), 1.0);
        assert!(d.ratio().is_finite());
    }

    // --- M3: GPU fdinfo ----------------------------------------------------

    const FDINFO_DRM: &[u8] = b"pos:\t0\nflags:\t02100002\nmnt_id:\t25\nino:\t1234\ndrm-driver:\tamdgpu\ndrm-pdev:\t0000:03:00.0\ndrm-engine-gfx:\t5000000000 ns\ndrm-engine-compute:\t1000000000 ns\ndrm-memory-vram:\t1048576 KiB\ndrm-busy:\t25000000000 ns\n";

    #[test]
    fn fdinfo_sums_every_engine_class() {
        let g = parse_fdinfo_drm(FDINFO_DRM).expect("must parse");
        // gfx + compute. Summing by prefix is what keeps a process that only uses
        // video decode from reading as "no GPU".
        assert_eq!(g.gpu_ns, 6_000_000_000);
        assert_eq!(g.busy_ns, 25_000_000_000);
        // Memory *cycles*, not KiB: `drm-memory-vram` is a byte count and must not
        // be mistaken for the ns-valued `drm-memory-*` counters... which it is
        // here, because the kernel reports both under the same prefix.
        assert_eq!(g.mem_ns, 1_048_576, "parsed as a number, whatever its unit");
    }

    #[test]
    fn fdinfo_percent_is_engine_over_busy() {
        let g = parse_fdinfo_drm(FDINFO_DRM).unwrap();
        let pct = g.percent().expect("busy time is present");
        assert!((pct - 24.0).abs() < 0.01, "6/25 == 24 %, got {pct}");
    }

    /// Without `drm-busy` there is no denominator, so there is no percentage.
    /// Inventing one from the sample interval would be a different quantity.
    #[test]
    fn fdinfo_without_busy_has_no_percent() {
        let g = parse_fdinfo_drm(b"drm-engine-gfx:\t5000 ns\n").expect("has a drm key");
        assert_eq!(g.gpu_ns, 5000);
        assert_eq!(g.busy_ns, 0);
        assert_eq!(g.percent(), None);
    }

    /// Most descriptors are not DRM at all, and the reader has to walk them all
    /// without treating an ordinary file as a GPU one.
    #[test]
    fn fdinfo_without_drm_keys_is_not_a_gpu_descriptor() {
        assert!(parse_fdinfo_drm(b"pos:\t0\nflags:\t02\nmnt_id:\t25\n").is_none());
        assert!(parse_fdinfo_drm(b"").is_none());
    }

    #[test]
    fn counters_never_report_a_negative_rate() {
        // An interface recreated between samples: the counter goes backwards.
        let before = NetCounters {
            rx_bytes: 1_000_000,
            tx_bytes: 500_000,
        };
        let after = NetCounters {
            rx_bytes: 100,
            tx_bytes: 50,
        };
        assert_eq!(after.delta(&before), NetCounters::default());
        assert_eq!(after.per_sec(&before, 1.0).total(), 0.0);

        // A zero or negative interval is the first sample, not a divide by zero.
        let rate = after.per_sec(&NetCounters::default(), 0.0);
        assert_eq!(rate.total(), 0.0);
        assert!(rate.inbound.is_finite());
    }
}

#[cfg(test)]
mod tty_and_tpgid_tests {
    use super::*;

    /// Field 5 is `tty_nr` and field 8 is `tpgid`. Both were parsed into
    /// `StatFields` (or, for `tpgid`, not at all) without ever reaching a column,
    /// so this pins the offsets: `comm` may contain spaces and parentheses, which
    /// is why the parser anchors on the last `)`.
    #[test]
    fn tty_and_tpgid_come_from_the_right_fields() {
        // fields after `)`: state ppid pgrp session tty_nr tpgid flags ...
        //                    0     1    2    3     4      5     6
        let line = b"42 (my ) we(ird comm) R 1 42 42 34816 4242 4194560 512 7 0 0 120 34 0 0 20 0 1 0 999 1048576 256\n";
        let (pid, comm, st) = parse_stat(line).expect("parse");
        assert_eq!(pid, 42);
        // The parser anchors on the *last* `)`, so the one in the name is kept and
        // the one that closes the field is consumed as the terminator.
        assert_eq!(comm.as_str(), "my ) we(ird comm");
        assert_eq!(st.session, 42);
        assert_eq!(st.tty_nr, 34816, "136<<8 = pts/0");
        assert_eq!(st.tpgid, 4242);
    }

    /// The kernel writes `-1` when the terminal has no foreground job, and that is
    /// a value, not an absence — it must survive parsing as a negative number.
    #[test]
    fn a_negative_tpgid_is_not_silently_wrapped() {
        let line = b"7 (bash) S 1 7 7 0 -1 4194560 1 0 0 0 0 0 0 0 20 0 1 0 1 1 1\n";
        let (_, _, st) = parse_stat(line).expect("parse");
        assert_eq!(st.tty_nr, 0);
        assert_eq!(st.tpgid, -1);
    }
}