Skip to main content

retch_sysinfo/
memory.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Physical memory (RAM) slot detection — type, speed, and capacity.
5
6pub fn detect_physical_memory() -> Option<String> {
7    #[cfg(target_os = "linux")]
8    return detect_linux();
9
10    #[cfg(target_os = "macos")]
11    return detect_macos();
12
13    #[cfg(target_os = "windows")]
14    return detect_windows();
15
16    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
17    return None;
18}
19
20/// Represents one DIMM slot as parsed from DMI type-17 output.
21#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
22#[derive(Debug, PartialEq)]
23pub struct DimmSlot {
24    pub size_mb: u64,
25    pub mem_type: String,
26    /// Rated/maximum speed the module supports.
27    pub speed_mt: Option<u64>,
28    /// Speed the module is actually clocked at, when the source distinguishes
29    /// it from the rated speed (currently: Linux dmidecode's "Configured
30    /// Memory Speed"). `None` if unknown or not reported separately.
31    pub configured_speed_mt: Option<u64>,
32}
33
34/// Parses `dmidecode --type 17` text output into a list of populated DIMM slots.
35#[cfg(target_os = "linux")]
36pub fn parse_dmidecode_type17(output: &str) -> Vec<DimmSlot> {
37    let mut slots = Vec::new();
38    let mut size_mb: Option<u64> = None;
39    let mut mem_type = String::new();
40    let mut speed_mt: Option<u64> = None;
41    let mut configured_speed_mt: Option<u64> = None;
42
43    for line in output.lines() {
44        let trimmed = line.trim();
45
46        if trimmed == "Memory Device" {
47            // Flush previous slot
48            if let Some(mb) = size_mb.take() {
49                if mb > 0 {
50                    slots.push(DimmSlot {
51                        size_mb: mb,
52                        mem_type: mem_type.clone(),
53                        speed_mt,
54                        configured_speed_mt,
55                    });
56                }
57            }
58            mem_type.clear();
59            speed_mt = None;
60            configured_speed_mt = None;
61        } else if let Some(rest) = trimmed.strip_prefix("Size:") {
62            let rest = rest.trim();
63            if rest.contains("No Module") || rest == "Unknown" {
64                size_mb = Some(0);
65            } else if let Some(n) = rest
66                .strip_suffix(" GiB")
67                .or_else(|| rest.strip_suffix(" GB"))
68            {
69                size_mb = n.trim().parse::<u64>().ok().map(|g| g * 1024);
70            } else if let Some(n) = rest
71                .strip_suffix(" MiB")
72                .or_else(|| rest.strip_suffix(" MB"))
73            {
74                size_mb = n.trim().parse::<u64>().ok();
75            }
76        } else if let Some(rest) = trimmed.strip_prefix("Type:") {
77            let t = rest.trim();
78            if t != "Unknown" && !t.is_empty() {
79                mem_type = t.to_string();
80            }
81        } else if let Some(rest) = trimmed.strip_prefix("Configured Memory Speed:") {
82            // e.g. "4000 MT/s" or "Unknown" — the speed the module is actually
83            // running at, which can be lower than "Speed:" (its rated max).
84            let rest = rest.trim();
85            if let Some(mt_str) = rest.strip_suffix(" MT/s") {
86                configured_speed_mt = mt_str.trim().parse::<u64>().ok();
87            }
88        } else if let Some(rest) = trimmed.strip_prefix("Speed:") {
89            // e.g. "4800 MT/s" or "Unknown"
90            let rest = rest.trim();
91            if let Some(mt_str) = rest.strip_suffix(" MT/s") {
92                speed_mt = mt_str.trim().parse::<u64>().ok();
93            }
94        }
95    }
96
97    // Flush last slot
98    if let Some(mb) = size_mb {
99        if mb > 0 {
100            slots.push(DimmSlot {
101                size_mb: mb,
102                mem_type,
103                speed_mt,
104                configured_speed_mt,
105            });
106        }
107    }
108
109    slots
110}
111
112/// Formats a list of DIMM slots into a human-readable summary string.
113#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
114pub fn format_dimm_slots(slots: &[DimmSlot]) -> Option<String> {
115    if slots.is_empty() {
116        return None;
117    }
118
119    // Group identical slots (same size + type + speed)
120    #[derive(PartialEq, Eq, Hash)]
121    struct Key {
122        size_mb: u64,
123        mem_type: String,
124        speed_mt: Option<u64>,
125        configured_speed_mt: Option<u64>,
126    }
127
128    let mut groups: Vec<(Key, usize)> = Vec::new();
129    for slot in slots {
130        let key = Key {
131            size_mb: slot.size_mb,
132            mem_type: slot.mem_type.clone(),
133            speed_mt: slot.speed_mt,
134            configured_speed_mt: slot.configured_speed_mt,
135        };
136        if let Some(entry) = groups.iter_mut().find(|(k, _)| k == &key) {
137            entry.1 += 1;
138        } else {
139            groups.push((key, 1));
140        }
141    }
142
143    let parts: Vec<String> = groups
144        .iter()
145        .map(|(key, count)| {
146            let size_str = if key.size_mb >= 1024 {
147                format!("{} GB", key.size_mb / 1024)
148            } else {
149                format!("{} MB", key.size_mb)
150            };
151
152            let mut s = if *count > 1 {
153                format!("{}× {}", count, size_str)
154            } else {
155                size_str
156            };
157
158            if !key.mem_type.is_empty() {
159                s.push(' ');
160                s.push_str(&key.mem_type);
161            }
162
163            match (key.speed_mt, key.configured_speed_mt) {
164                (Some(rated), Some(running)) if running != rated => {
165                    s.push_str(&format!(" {} MT/s (rated {} MT/s)", running, rated));
166                }
167                (Some(mt), _) => {
168                    s.push_str(&format!(" {} MT/s", mt));
169                }
170                (None, Some(running)) => {
171                    s.push_str(&format!(" {} MT/s", running));
172                }
173                (None, None) => {}
174            }
175
176            s
177        })
178        .collect();
179
180    Some(parts.join(", "))
181}
182
183#[cfg(target_os = "linux")]
184fn detect_linux() -> Option<String> {
185    // Try name-only first (works when /usr/bin is in PATH), then known absolute paths.
186    let candidates = ["dmidecode", "/usr/bin/dmidecode", "/usr/sbin/dmidecode"];
187    for cmd in candidates {
188        let Ok(output) = std::process::Command::new(cmd)
189            .args(["--type", "17"])
190            .output()
191        else {
192            continue;
193        };
194        if !output.status.success() {
195            continue;
196        }
197        let text = String::from_utf8_lossy(&output.stdout);
198        let slots = parse_dmidecode_type17(&text);
199        return format_dimm_slots(&slots);
200    }
201    None
202}
203
204#[cfg(target_os = "macos")]
205fn detect_macos() -> Option<String> {
206    let output = std::process::Command::new("system_profiler")
207        .args(["SPMemoryDataType", "-detailLevel", "basic"])
208        .output()
209        .ok()?;
210
211    if !output.status.success() {
212        return None;
213    }
214
215    let text = String::from_utf8_lossy(&output.stdout);
216    parse_system_profiler_memory(&text)
217}
218
219/// Parses `system_profiler SPMemoryDataType` text output into a summary string.
220#[cfg(target_os = "macos")]
221pub fn parse_system_profiler_memory(text: &str) -> Option<String> {
222    // Example output:
223    //   Memory:
224    //     Type: LPDDR5
225    //     Speed: 6400 MT/s
226    //     Manufacturers: SK Hynix
227    //     Size: 16 GB
228    // Or for multi-slot Macs:
229    //   BANK 0/DIMM0:
230    //     Size: 16 GB
231    //     Type: DDR5
232    //     Speed: 4800 MT/s
233
234    let mut slots: Vec<DimmSlot> = Vec::new();
235    let mut current_size_mb: Option<u64> = None;
236    let mut current_type = String::new();
237    let mut current_speed: Option<u64> = None;
238    let mut in_slot = false;
239
240    for line in text.lines() {
241        let trimmed = line.trim();
242
243        // Slot/bank header (indented section headers ending with ':')
244        if (trimmed.contains("DIMM") || trimmed.contains("BANK") || trimmed.contains("Slot"))
245            && trimmed.ends_with(':')
246        {
247            if in_slot {
248                if let Some(mb) = current_size_mb.take() {
249                    if mb > 0 {
250                        slots.push(DimmSlot {
251                            size_mb: mb,
252                            mem_type: current_type.clone(),
253                            speed_mt: current_speed,
254                            configured_speed_mt: None,
255                        });
256                    }
257                }
258                current_type.clear();
259                current_speed = None;
260            }
261            in_slot = true;
262            continue;
263        }
264
265        // "Size:" is used on physical Macs; VMs report the total as "Memory:" instead
266        let size_rest = trimmed.strip_prefix("Size:").or_else(|| {
267            trimmed
268                .strip_prefix("Memory:")
269                .filter(|s| !s.trim().is_empty())
270        });
271        if let Some(rest) = size_rest {
272            let rest = rest.trim();
273            if rest.contains("Empty") || rest == "Unknown" {
274                current_size_mb = Some(0);
275            } else if let Some(gb_str) = rest.strip_suffix(" GB") {
276                current_size_mb = gb_str.trim().parse::<u64>().ok().map(|g| g * 1024);
277            } else if let Some(mb_str) = rest.strip_suffix(" MB") {
278                current_size_mb = mb_str.trim().parse::<u64>().ok();
279            }
280        } else if let Some(rest) = trimmed.strip_prefix("Type:") {
281            let t = rest.trim();
282            if t != "Unknown" && !t.is_empty() {
283                current_type = t.to_string();
284            }
285        } else if let Some(rest) = trimmed.strip_prefix("Speed:") {
286            let rest = rest.trim();
287            if let Some(mt_str) = rest.strip_suffix(" MT/s") {
288                current_speed = mt_str.trim().parse::<u64>().ok();
289            } else if let Some(mhz_str) = rest.strip_suffix(" MHz") {
290                // Older Macs report MHz
291                current_speed = mhz_str.trim().parse::<u64>().ok().map(|mhz| mhz * 2);
292            }
293        }
294    }
295
296    // Flush last slot
297    if let Some(mb) = current_size_mb {
298        if mb > 0 {
299            slots.push(DimmSlot {
300                size_mb: mb,
301                mem_type: current_type,
302                speed_mt: current_speed,
303                configured_speed_mt: None,
304            });
305        }
306    }
307
308    // Apple Silicon unified memory: system_profiler often reports a single "Size" at the top level
309    // without slot headers — we'll have one slot from the flush above.
310
311    format_dimm_slots(&slots)
312}
313
314/// Detects physical memory on Windows via the raw SMBIOS table.
315///
316/// Replaces the previous `Get-CimInstance Win32_PhysicalMemory` PowerShell spawns
317/// (~600 ms, ×2) with `GetSystemFirmwareTable('RSMB')`, parsing SMBIOS type-17 (Memory
318/// Device) structures directly. This also picks up the *Configured Memory Speed* field
319/// (the actual running speed) that the WMI path did not expose, so Windows now matches
320/// the Linux dmidecode output (e.g. "4800 MT/s (rated 6000 MT/s)").
321#[cfg(target_os = "windows")]
322fn detect_windows() -> Option<String> {
323    if let Some(table) = win_ffi::read_smbios_table() {
324        let slots = parse_smbios_type17(&table);
325        if let Some(result) = format_dimm_slots(&slots) {
326            return Some(result);
327        }
328    }
329
330    // Hyper-V and other VMs expose no DIMM structures in SMBIOS. Fall back to the total
331    // physical memory (via GlobalMemoryStatusEx) so the field still appears.
332    let total_bytes = win_ffi::total_physical_memory()?;
333    if total_bytes == 0 {
334        return None;
335    }
336    let gb = total_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
337    Some(format!("{:.0} GB (VM — DIMM info unavailable)", gb))
338}
339
340/// Parses the SMBIOS structure table (the `SMBIOSTableData` portion of `RawSMBIOSData`,
341/// i.e. after the 8-byte header) into a list of populated DIMM slots.
342///
343/// Walks each structure by its formatted length + the double-null-terminated string set,
344/// extracting type-17 (Memory Device) fields. All field reads are bounded by the
345/// structure's declared length, so shorter (older SMBIOS-version) structures are safe.
346#[cfg(target_os = "windows")]
347pub fn parse_smbios_type17(data: &[u8]) -> Vec<DimmSlot> {
348    // Little-endian u16/u32 readers, bounds-checked against the formatted area `s`.
349    fn u16_at(s: &[u8], off: usize) -> Option<u16> {
350        s.get(off..off + 2)
351            .map(|b| u16::from_le_bytes([b[0], b[1]]))
352    }
353    fn u32_at(s: &[u8], off: usize) -> Option<u32> {
354        s.get(off..off + 4)
355            .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
356    }
357
358    let mut slots = Vec::new();
359    let mut i = 0usize;
360    while i + 4 <= data.len() {
361        let typ = data[i];
362        let slen = data[i + 1] as usize; // length of the formatted area
363        if slen < 4 || i + slen > data.len() {
364            break; // malformed / truncated
365        }
366        if typ == 127 {
367            break; // end-of-table marker
368        }
369        if typ == 17 {
370            let s = &data[i..i + slen];
371            if let Some(slot) = parse_type17_struct(s) {
372                slots.push(slot);
373            }
374        }
375        // Advance past the formatted area, then the string set (terminated by 0x00 0x00).
376        let mut j = i + slen;
377        loop {
378            if j + 1 >= data.len() {
379                j = data.len();
380                break;
381            }
382            if data[j] == 0 && data[j + 1] == 0 {
383                j += 2;
384                break;
385            }
386            j += 1;
387        }
388        i = j;
389    }
390
391    /// Parses a single type-17 formatted area into a `DimmSlot`, or `None` for an
392    /// empty/unpopulated slot.
393    fn parse_type17_struct(s: &[u8]) -> Option<DimmSlot> {
394        // Size (WORD @ 0x0C): 0 = empty; 0x7FFF = use Extended Size (DWORD @ 0x1C, in MB);
395        // otherwise bit15 selects unit (0=MB, 1=KB) and bits 0..14 are the value.
396        let size_raw = u16_at(s, 0x0C)?;
397        if size_raw == 0 {
398            return None;
399        }
400        let size_mb = if size_raw == 0x7FFF {
401            (u32_at(s, 0x1C)? & 0x7FFF_FFFF) as u64
402        } else {
403            let val = (size_raw & 0x7FFF) as u64;
404            if size_raw & 0x8000 != 0 {
405                val / 1024
406            } else {
407                val
408            }
409        };
410        if size_mb == 0 {
411            return None;
412        }
413
414        let mem_type = byte_at(s, 0x12)
415            .map(|code| smbios_memory_type(u16::from(code)))
416            .unwrap_or_default();
417
418        // Speed (WORD @ 0x15 — 0x13 is the Type Detail WORD) and Configured Memory Speed
419        // (WORD @ 0x20), both MT/s. 0 = unknown; 0xFFFF = use the extended DWORD (3.3+).
420        let speed_mt = read_speed(s, 0x15, 0x54);
421        let configured_speed_mt = read_speed(s, 0x20, 0x58);
422
423        Some(DimmSlot {
424            size_mb,
425            mem_type,
426            speed_mt,
427            configured_speed_mt,
428        })
429    }
430
431    /// Reads a byte field if present.
432    fn byte_at(s: &[u8], off: usize) -> Option<u8> {
433        s.get(off).copied()
434    }
435
436    /// Reads an MT/s speed at `word_off`, following the 0xFFFF -> extended DWORD indirection
437    /// at `ext_off`. Returns `None` if unknown/absent.
438    fn read_speed(s: &[u8], word_off: usize, ext_off: usize) -> Option<u64> {
439        match u16_at(s, word_off) {
440            None | Some(0) => None,
441            Some(0xFFFF) => u32_at(s, ext_off).filter(|&v| v > 0).map(|v| v as u64),
442            Some(v) => Some(v as u64),
443        }
444    }
445
446    slots
447}
448
449/// Maps SMBIOS memory type codes (from `Win32_PhysicalMemory.SMBIOSMemoryType`) to strings.
450#[cfg(target_os = "windows")]
451fn smbios_memory_type(code: u16) -> String {
452    match code {
453        20 => "DDR".to_string(),
454        21 => "DDR2".to_string(),
455        24 => "DDR3".to_string(),
456        26 => "DDR4".to_string(),
457        27 => "LPDDR".to_string(),
458        28 => "LPDDR2".to_string(),
459        29 => "LPDDR3".to_string(),
460        30 => "LPDDR4".to_string(),
461        34 => "DDR5".to_string(),
462        35 => "LPDDR5".to_string(),
463        _ => String::new(),
464    }
465}
466
467/// Native Win32 bindings for reading the raw SMBIOS table and total physical memory.
468///
469/// Hand-written `extern "system"` declarations matching the crate's Windows FFI style
470/// (`win_reg.rs`) — no Win32 binding crate.
471#[cfg(target_os = "windows")]
472mod win_ffi {
473    use std::ffi::c_void;
474    use std::mem::size_of;
475
476    // 'RSMB' provider signature, as the multi-char DWORD Windows expects.
477    const RSMB: u32 = u32::from_be_bytes(*b"RSMB");
478    // RawSMBIOSData header: Used20CallingMethod, Major, Minor, DmiRevision (4 bytes),
479    // then a u32 Length, then the SMBIOS structure table.
480    const RAW_SMBIOS_HEADER_LEN: usize = 8;
481
482    #[repr(C)]
483    struct MemoryStatusEx {
484        length: u32,
485        memory_load: u32,
486        total_phys: u64,
487        avail_phys: u64,
488        total_page_file: u64,
489        avail_page_file: u64,
490        total_virtual: u64,
491        avail_virtual: u64,
492        avail_extended_virtual: u64,
493    }
494
495    extern "system" {
496        fn GetSystemFirmwareTable(
497            firmware_table_provider_signature: u32,
498            firmware_table_id: u32,
499            firmware_table_buffer: *mut c_void,
500            buffer_size: u32,
501        ) -> u32;
502
503        fn GlobalMemoryStatusEx(buffer: *mut MemoryStatusEx) -> i32;
504    }
505
506    /// Reads the SMBIOS structure table (the `SMBIOSTableData` region, header stripped),
507    /// or `None` if unavailable.
508    pub fn read_smbios_table() -> Option<Vec<u8>> {
509        // SAFETY: querying with a null buffer/zero size returns the required size.
510        let needed = unsafe { GetSystemFirmwareTable(RSMB, 0, std::ptr::null_mut(), 0) };
511        if needed == 0 {
512            return None;
513        }
514        let mut buf = vec![0u8; needed as usize];
515        // SAFETY: buf is `needed` bytes and its length is passed as the buffer size.
516        let written =
517            unsafe { GetSystemFirmwareTable(RSMB, 0, buf.as_mut_ptr() as *mut c_void, needed) };
518        if written == 0 || (written as usize) > buf.len() {
519            return None;
520        }
521        buf.truncate(written as usize);
522        if buf.len() < RAW_SMBIOS_HEADER_LEN {
523            return None;
524        }
525        // Length field (u32 LE) at offset 4 gives the table size after the 8-byte header.
526        let table_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as usize;
527        let end = RAW_SMBIOS_HEADER_LEN.checked_add(table_len)?;
528        let end = end.min(buf.len());
529        Some(buf[RAW_SMBIOS_HEADER_LEN..end].to_vec())
530    }
531
532    /// Returns total installed physical memory in bytes via `GlobalMemoryStatusEx`.
533    pub fn total_physical_memory() -> Option<u64> {
534        let mut status = MemoryStatusEx {
535            length: size_of::<MemoryStatusEx>() as u32,
536            memory_load: 0,
537            total_phys: 0,
538            avail_phys: 0,
539            total_page_file: 0,
540            avail_page_file: 0,
541            total_virtual: 0,
542            avail_virtual: 0,
543            avail_extended_virtual: 0,
544        };
545        // SAFETY: status is a valid MemoryStatusEx with `length` set to its own size.
546        let ok = unsafe { GlobalMemoryStatusEx(&mut status) };
547        if ok == 0 {
548            None
549        } else {
550            Some(status.total_phys)
551        }
552    }
553
554    #[cfg(test)]
555    mod layout {
556        use std::mem::size_of;
557
558        // `MemoryStatusEx.length` must equal the OS's sizeof or GlobalMemoryStatusEx fails.
559        #[test]
560        fn ffi_struct_layout() {
561            assert_eq!(size_of::<super::MemoryStatusEx>(), 64);
562        }
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    #[cfg(target_os = "linux")]
571    #[test]
572    fn test_parse_dmidecode_two_slots() {
573        let input = r#"
574Memory Device
575        Size: 8 GB
576        Type: DDR5
577        Speed: 4800 MT/s
578
579Memory Device
580        Size: 8 GB
581        Type: DDR5
582        Speed: 4800 MT/s
583"#;
584        let slots = parse_dmidecode_type17(input);
585        assert_eq!(slots.len(), 2);
586        assert_eq!(slots[0].size_mb, 8192);
587        assert_eq!(slots[0].mem_type, "DDR5");
588        assert_eq!(slots[0].speed_mt, Some(4800));
589
590        let summary = format_dimm_slots(&slots).unwrap();
591        assert_eq!(summary, "2× 8 GB DDR5 4800 MT/s");
592    }
593
594    #[cfg(target_os = "linux")]
595    #[test]
596    fn test_parse_dmidecode_configured_speed_below_rated() {
597        // XMP/EXPO not enabled — modules run below their rated speed.
598        let input = r#"
599Memory Device
600        Size: 16 GB
601        Type: DDR5
602        Speed: 6000 MT/s
603        Configured Memory Speed: 4800 MT/s
604
605Memory Device
606        Size: 16 GB
607        Type: DDR5
608        Speed: 6000 MT/s
609        Configured Memory Speed: 4800 MT/s
610"#;
611        let slots = parse_dmidecode_type17(input);
612        assert_eq!(slots[0].speed_mt, Some(6000));
613        assert_eq!(slots[0].configured_speed_mt, Some(4800));
614
615        let summary = format_dimm_slots(&slots).unwrap();
616        assert_eq!(summary, "2× 16 GB DDR5 4800 MT/s (rated 6000 MT/s)");
617    }
618
619    #[cfg(target_os = "linux")]
620    #[test]
621    fn test_parse_dmidecode_configured_speed_matches_rated() {
622        // Configured speed equal to rated — no redundant "(rated ...)" suffix.
623        let input = r#"
624Memory Device
625        Size: 8 GB
626        Type: DDR5
627        Speed: 4800 MT/s
628        Configured Memory Speed: 4800 MT/s
629"#;
630        let slots = parse_dmidecode_type17(input);
631        let summary = format_dimm_slots(&slots).unwrap();
632        assert_eq!(summary, "8 GB DDR5 4800 MT/s");
633    }
634
635    #[cfg(target_os = "linux")]
636    #[test]
637    fn test_parse_dmidecode_gib_units() {
638        // dmidecode reports GiB on some systems (e.g. LPDDR5 laptops)
639        let input = r#"
640Memory Device
641    Size: 2 GiB
642    Type: LPDDR5
643    Speed: 6400 MT/s
644
645Memory Device
646    Size: 2 GiB
647    Type: LPDDR5
648    Speed: 6400 MT/s
649"#;
650        let slots = parse_dmidecode_type17(input);
651        assert_eq!(slots.len(), 2);
652        assert_eq!(slots[0].size_mb, 2048);
653        assert_eq!(slots[0].mem_type, "LPDDR5");
654        assert_eq!(slots[0].speed_mt, Some(6400));
655
656        let summary = format_dimm_slots(&slots).unwrap();
657        assert_eq!(summary, "2× 2 GB LPDDR5 6400 MT/s");
658    }
659
660    #[cfg(target_os = "linux")]
661    #[test]
662    fn test_parse_dmidecode_empty_slot() {
663        let input = r#"
664Memory Device
665        Size: No Module Installed
666        Type: Unknown
667
668Memory Device
669        Size: 16 GB
670        Type: DDR4
671        Speed: 3200 MT/s
672"#;
673        let slots = parse_dmidecode_type17(input);
674        assert_eq!(slots.len(), 1);
675        assert_eq!(slots[0].size_mb, 16384);
676        let summary = format_dimm_slots(&slots).unwrap();
677        assert_eq!(summary, "16 GB DDR4 3200 MT/s");
678    }
679
680    #[cfg(target_os = "linux")]
681    #[test]
682    fn test_parse_dmidecode_mixed_sizes() {
683        let input = r#"
684Memory Device
685        Size: 16 GB
686        Type: DDR5
687        Speed: 5600 MT/s
688
689Memory Device
690        Size: 32 GB
691        Type: DDR5
692        Speed: 5600 MT/s
693"#;
694        let slots = parse_dmidecode_type17(input);
695        assert_eq!(slots.len(), 2);
696        let summary = format_dimm_slots(&slots).unwrap();
697        // Two different sizes → two groups
698        assert!(summary.contains("16 GB DDR5 5600 MT/s"));
699        assert!(summary.contains("32 GB DDR5 5600 MT/s"));
700    }
701
702    #[cfg(target_os = "macos")]
703    #[test]
704    fn test_parse_system_profiler_apple_silicon() {
705        let input = r#"
706Memory:
707
708      Type: LPDDR5
709      Speed: 6400 MT/s
710      Size: 16 GB
711"#;
712        let result = parse_system_profiler_memory(input);
713        assert_eq!(result, Some("16 GB LPDDR5 6400 MT/s".to_string()));
714    }
715
716    #[cfg(target_os = "macos")]
717    #[test]
718    fn test_parse_system_profiler_multi_slot() {
719        let input = r#"
720Memory:
721
722    BANK 0/DIMM0:
723
724      Size: 16 GB
725      Type: DDR5
726      Speed: 4800 MT/s
727
728    BANK 1/DIMM0:
729
730      Size: 16 GB
731      Type: DDR5
732      Speed: 4800 MT/s
733"#;
734        let result = parse_system_profiler_memory(input);
735        assert_eq!(result, Some("2× 16 GB DDR5 4800 MT/s".to_string()));
736    }
737
738    /// Builds one SMBIOS type-17 (Memory Device) structure: a 34-byte formatted area
739    /// (SMBIOS 2.7, covering through Configured Memory Speed) plus a double-null-terminated
740    /// string set. `size_word` is the raw Size field (MB when bit15 is clear).
741    #[cfg(target_os = "windows")]
742    fn make_type17(size_word: u16, type_code: u8, speed: u16, configured: u16) -> Vec<u8> {
743        let mut s = vec![0u8; 0x22];
744        s[0] = 17; // Type = Memory Device
745        s[1] = 0x22; // Length = 34
746        s[2] = 0x00; // Handle (arbitrary)
747        s[3] = 0x11;
748        s[0x0C..0x0E].copy_from_slice(&size_word.to_le_bytes());
749        s[0x12] = type_code;
750        s[0x15..0x17].copy_from_slice(&speed.to_le_bytes()); // Speed @ 0x15 (0x13 = Type Detail)
751        s[0x20..0x22].copy_from_slice(&configured.to_le_bytes());
752        // Empty string set: terminating double-null.
753        s.push(0);
754        s.push(0);
755        s
756    }
757
758    /// Appends the end-of-table (type 127) structure.
759    #[cfg(target_os = "windows")]
760    fn end_marker() -> Vec<u8> {
761        vec![127, 4, 0x00, 0x7F, 0, 0]
762    }
763
764    #[cfg(target_os = "windows")]
765    #[test]
766    fn test_parse_smbios_type17_ddr4_two_slots() {
767        // 8 GB = 8192 MB = 0x2000 (bit15 clear -> MB), DDR4 (26), 3200 MT/s, no configured.
768        let mut data = make_type17(0x2000, 26, 3200, 0);
769        data.extend(make_type17(0x2000, 26, 3200, 0));
770        data.extend(end_marker());
771        let slots = parse_smbios_type17(&data);
772        assert_eq!(slots.len(), 2);
773        assert_eq!(slots[0].size_mb, 8192);
774        assert_eq!(slots[0].mem_type, "DDR4");
775        assert_eq!(slots[0].speed_mt, Some(3200));
776        assert_eq!(format_dimm_slots(&slots).unwrap(), "2× 8 GB DDR4 3200 MT/s");
777    }
778
779    #[cfg(target_os = "windows")]
780    #[test]
781    fn test_parse_smbios_type17_ddr5_configured_below_rated() {
782        // 16 GB = 16384 MB = 0x4000, DDR5 (34), rated 6000, running 4800 (XMP/EXPO off).
783        // This is the field the old WMI path could not read.
784        let mut data = make_type17(0x4000, 34, 6000, 4800);
785        data.extend(end_marker());
786        let slots = parse_smbios_type17(&data);
787        assert_eq!(slots.len(), 1);
788        assert_eq!(slots[0].speed_mt, Some(6000));
789        assert_eq!(slots[0].configured_speed_mt, Some(4800));
790        assert_eq!(
791            format_dimm_slots(&slots).unwrap(),
792            "16 GB DDR5 4800 MT/s (rated 6000 MT/s)"
793        );
794    }
795
796    #[cfg(target_os = "windows")]
797    #[test]
798    fn test_parse_smbios_type17_skips_empty_slot() {
799        // Size 0 = unpopulated slot -> skipped; one populated 16 GB DDR5 remains.
800        // (16 GB = 0x4000 MB fits the 15-bit Size field with bit15 clear; ≥32 GB would
801        // require the Extended Size path, covered separately.)
802        let mut data = make_type17(0, 0, 0, 0);
803        data.extend(make_type17(0x4000, 34, 4800, 4800));
804        data.extend(end_marker());
805        let slots = parse_smbios_type17(&data);
806        assert_eq!(slots.len(), 1);
807        assert_eq!(slots[0].size_mb, 16384);
808        assert_eq!(format_dimm_slots(&slots).unwrap(), "16 GB DDR5 4800 MT/s");
809    }
810
811    #[cfg(target_os = "windows")]
812    #[test]
813    fn test_parse_smbios_type17_unknown_type_no_label() {
814        // Memory type 0 -> no type label; speed 0 -> no speed suffix.
815        let mut data = make_type17(0x4000, 0, 0, 0);
816        data.extend(end_marker());
817        let slots = parse_smbios_type17(&data);
818        assert_eq!(format_dimm_slots(&slots).unwrap(), "16 GB");
819    }
820
821    #[cfg(target_os = "windows")]
822    #[test]
823    fn test_parse_smbios_type17_size_in_kb() {
824        // bit15 set -> value is in KB. 0x8000 | 0x0400 => 1024 KB = 1 MB.
825        let mut data = make_type17(0x8000 | 0x0400, 26, 3200, 0);
826        data.extend(end_marker());
827        let slots = parse_smbios_type17(&data);
828        assert_eq!(slots.len(), 1);
829        assert_eq!(slots[0].size_mb, 1);
830    }
831
832    #[cfg(target_os = "windows")]
833    #[test]
834    fn test_parse_smbios_type17_extended_size() {
835        // Size == 0x7FFF -> read Extended Size DWORD @ 0x1C (in MB). 65536 MB = 64 GB.
836        let mut data = make_type17(0x7FFF, 34, 4800, 0);
837        data[0x1C..0x20].copy_from_slice(&65536u32.to_le_bytes());
838        data.extend(end_marker());
839        let slots = parse_smbios_type17(&data);
840        assert_eq!(slots.len(), 1);
841        assert_eq!(slots[0].size_mb, 65536);
842    }
843
844    #[cfg(target_os = "windows")]
845    #[test]
846    fn test_parse_smbios_type17_empty_table() {
847        assert!(parse_smbios_type17(&end_marker()).is_empty());
848        assert!(parse_smbios_type17(&[]).is_empty());
849    }
850}