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    pub speed_mt: Option<u64>,
27}
28
29/// Parses `dmidecode --type 17` text output into a list of populated DIMM slots.
30#[cfg(target_os = "linux")]
31pub fn parse_dmidecode_type17(output: &str) -> Vec<DimmSlot> {
32    let mut slots = Vec::new();
33    let mut size_mb: Option<u64> = None;
34    let mut mem_type = String::new();
35    let mut speed_mt: Option<u64> = None;
36
37    for line in output.lines() {
38        let trimmed = line.trim();
39
40        if trimmed == "Memory Device" {
41            // Flush previous slot
42            if let Some(mb) = size_mb.take() {
43                if mb > 0 {
44                    slots.push(DimmSlot {
45                        size_mb: mb,
46                        mem_type: mem_type.clone(),
47                        speed_mt,
48                    });
49                }
50            }
51            mem_type.clear();
52            speed_mt = None;
53        } else if let Some(rest) = trimmed.strip_prefix("Size:") {
54            let rest = rest.trim();
55            if rest.contains("No Module") || rest == "Unknown" {
56                size_mb = Some(0);
57            } else if let Some(n) = rest
58                .strip_suffix(" GiB")
59                .or_else(|| rest.strip_suffix(" GB"))
60            {
61                size_mb = n.trim().parse::<u64>().ok().map(|g| g * 1024);
62            } else if let Some(n) = rest
63                .strip_suffix(" MiB")
64                .or_else(|| rest.strip_suffix(" MB"))
65            {
66                size_mb = n.trim().parse::<u64>().ok();
67            }
68        } else if let Some(rest) = trimmed.strip_prefix("Type:") {
69            let t = rest.trim();
70            if t != "Unknown" && !t.is_empty() {
71                mem_type = t.to_string();
72            }
73        } else if let Some(rest) = trimmed.strip_prefix("Speed:") {
74            // e.g. "4800 MT/s" or "Unknown"
75            let rest = rest.trim();
76            if let Some(mt_str) = rest.strip_suffix(" MT/s") {
77                speed_mt = mt_str.trim().parse::<u64>().ok();
78            }
79        }
80    }
81
82    // Flush last slot
83    if let Some(mb) = size_mb {
84        if mb > 0 {
85            slots.push(DimmSlot {
86                size_mb: mb,
87                mem_type,
88                speed_mt,
89            });
90        }
91    }
92
93    slots
94}
95
96/// Formats a list of DIMM slots into a human-readable summary string.
97#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
98pub fn format_dimm_slots(slots: &[DimmSlot]) -> Option<String> {
99    if slots.is_empty() {
100        return None;
101    }
102
103    // Group identical slots (same size + type + speed)
104    #[derive(PartialEq, Eq, Hash)]
105    struct Key {
106        size_mb: u64,
107        mem_type: String,
108        speed_mt: Option<u64>,
109    }
110
111    let mut groups: Vec<(Key, usize)> = Vec::new();
112    for slot in slots {
113        let key = Key {
114            size_mb: slot.size_mb,
115            mem_type: slot.mem_type.clone(),
116            speed_mt: slot.speed_mt,
117        };
118        if let Some(entry) = groups.iter_mut().find(|(k, _)| k == &key) {
119            entry.1 += 1;
120        } else {
121            groups.push((key, 1));
122        }
123    }
124
125    let parts: Vec<String> = groups
126        .iter()
127        .map(|(key, count)| {
128            let size_str = if key.size_mb >= 1024 {
129                format!("{} GB", key.size_mb / 1024)
130            } else {
131                format!("{} MB", key.size_mb)
132            };
133
134            let mut s = if *count > 1 {
135                format!("{}× {}", count, size_str)
136            } else {
137                size_str
138            };
139
140            if !key.mem_type.is_empty() {
141                s.push(' ');
142                s.push_str(&key.mem_type);
143            }
144
145            if let Some(mt) = key.speed_mt {
146                s.push_str(&format!(" {} MT/s", mt));
147            }
148
149            s
150        })
151        .collect();
152
153    Some(parts.join(", "))
154}
155
156#[cfg(target_os = "linux")]
157fn detect_linux() -> Option<String> {
158    // Try name-only first (works when /usr/bin is in PATH), then known absolute paths.
159    let candidates = ["dmidecode", "/usr/bin/dmidecode", "/usr/sbin/dmidecode"];
160    for cmd in candidates {
161        let Ok(output) = std::process::Command::new(cmd)
162            .args(["--type", "17"])
163            .output()
164        else {
165            continue;
166        };
167        if !output.status.success() {
168            continue;
169        }
170        let text = String::from_utf8_lossy(&output.stdout);
171        let slots = parse_dmidecode_type17(&text);
172        return format_dimm_slots(&slots);
173    }
174    None
175}
176
177#[cfg(target_os = "macos")]
178fn detect_macos() -> Option<String> {
179    let output = std::process::Command::new("system_profiler")
180        .args(["SPMemoryDataType", "-detailLevel", "basic"])
181        .output()
182        .ok()?;
183
184    if !output.status.success() {
185        return None;
186    }
187
188    let text = String::from_utf8_lossy(&output.stdout);
189    parse_system_profiler_memory(&text)
190}
191
192/// Parses `system_profiler SPMemoryDataType` text output into a summary string.
193#[cfg(target_os = "macos")]
194pub fn parse_system_profiler_memory(text: &str) -> Option<String> {
195    // Example output:
196    //   Memory:
197    //     Type: LPDDR5
198    //     Speed: 6400 MT/s
199    //     Manufacturers: SK Hynix
200    //     Size: 16 GB
201    // Or for multi-slot Macs:
202    //   BANK 0/DIMM0:
203    //     Size: 16 GB
204    //     Type: DDR5
205    //     Speed: 4800 MT/s
206
207    let mut slots: Vec<DimmSlot> = Vec::new();
208    let mut current_size_mb: Option<u64> = None;
209    let mut current_type = String::new();
210    let mut current_speed: Option<u64> = None;
211    let mut in_slot = false;
212
213    for line in text.lines() {
214        let trimmed = line.trim();
215
216        // Slot/bank header (indented section headers ending with ':')
217        if (trimmed.contains("DIMM") || trimmed.contains("BANK") || trimmed.contains("Slot"))
218            && trimmed.ends_with(':')
219        {
220            if in_slot {
221                if let Some(mb) = current_size_mb.take() {
222                    if mb > 0 {
223                        slots.push(DimmSlot {
224                            size_mb: mb,
225                            mem_type: current_type.clone(),
226                            speed_mt: current_speed,
227                        });
228                    }
229                }
230                current_type.clear();
231                current_speed = None;
232            }
233            in_slot = true;
234            continue;
235        }
236
237        // "Size:" is used on physical Macs; VMs report the total as "Memory:" instead
238        let size_rest = trimmed.strip_prefix("Size:").or_else(|| {
239            trimmed
240                .strip_prefix("Memory:")
241                .filter(|s| !s.trim().is_empty())
242        });
243        if let Some(rest) = size_rest {
244            let rest = rest.trim();
245            if rest.contains("Empty") || rest == "Unknown" {
246                current_size_mb = Some(0);
247            } else if let Some(gb_str) = rest.strip_suffix(" GB") {
248                current_size_mb = gb_str.trim().parse::<u64>().ok().map(|g| g * 1024);
249            } else if let Some(mb_str) = rest.strip_suffix(" MB") {
250                current_size_mb = mb_str.trim().parse::<u64>().ok();
251            }
252        } else if let Some(rest) = trimmed.strip_prefix("Type:") {
253            let t = rest.trim();
254            if t != "Unknown" && !t.is_empty() {
255                current_type = t.to_string();
256            }
257        } else if let Some(rest) = trimmed.strip_prefix("Speed:") {
258            let rest = rest.trim();
259            if let Some(mt_str) = rest.strip_suffix(" MT/s") {
260                current_speed = mt_str.trim().parse::<u64>().ok();
261            } else if let Some(mhz_str) = rest.strip_suffix(" MHz") {
262                // Older Macs report MHz
263                current_speed = mhz_str.trim().parse::<u64>().ok().map(|mhz| mhz * 2);
264            }
265        }
266    }
267
268    // Flush last slot
269    if let Some(mb) = current_size_mb {
270        if mb > 0 {
271            slots.push(DimmSlot {
272                size_mb: mb,
273                mem_type: current_type,
274                speed_mt: current_speed,
275            });
276        }
277    }
278
279    // Apple Silicon unified memory: system_profiler often reports a single "Size" at the top level
280    // without slot headers — we'll have one slot from the flush above.
281
282    format_dimm_slots(&slots)
283}
284
285#[cfg(target_os = "windows")]
286fn detect_windows() -> Option<String> {
287    let cmd = "Get-CimInstance Win32_PhysicalMemory | \
288               Select-Object Capacity,SMBIOSMemoryType,Speed | \
289               ConvertTo-Csv -NoTypeInformation";
290    if let Ok(output) = std::process::Command::new("powershell")
291        .args(["-NoProfile", "-NonInteractive", "-Command", cmd])
292        .output()
293    {
294        if output.status.success() {
295            let text = String::from_utf8_lossy(&output.stdout);
296            if let Some(result) = parse_win32_physical_memory(&text) {
297                return Some(result);
298            }
299        }
300    }
301
302    // Hyper-V and other VMs don't expose DIMM rows in Win32_PhysicalMemory.
303    // Fall back to the total from Win32_ComputerSystem so the field still appears.
304    let fb_cmd = "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory";
305    let Ok(fb) = std::process::Command::new("powershell")
306        .args(["-NoProfile", "-NonInteractive", "-Command", fb_cmd])
307        .output()
308    else {
309        return None;
310    };
311    if !fb.status.success() {
312        return None;
313    }
314    let total_bytes: u64 = String::from_utf8_lossy(&fb.stdout).trim().parse().ok()?;
315    if total_bytes == 0 {
316        return None;
317    }
318    let gb = total_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
319    Some(format!("{:.0} GB (VM — DIMM info unavailable)", gb))
320}
321
322/// Parses `Get-CimInstance Win32_PhysicalMemory | ConvertTo-Csv` output.
323///
324/// Expected CSV header: `"Capacity","SMBIOSMemoryType","Speed"`
325/// Speed is in MT/s (Win32 already uses the transfer-rate value, e.g. 3200 for DDR4-3200).
326#[cfg(target_os = "windows")]
327pub fn parse_win32_physical_memory(csv: &str) -> Option<String> {
328    let mut lines = csv.lines();
329    lines.next(); // skip header
330    let mut slots = Vec::new();
331    for line in lines {
332        let fields = unquoted_csv_fields(line);
333        if fields.len() < 3 {
334            continue;
335        }
336        let capacity_bytes: u64 = fields[0].trim().parse().unwrap_or(0);
337        if capacity_bytes == 0 {
338            continue;
339        }
340        let size_mb = capacity_bytes / (1024 * 1024);
341        let type_code: u16 = fields[1].trim().parse().unwrap_or(0);
342        let speed_mt: u64 = fields[2].trim().parse().unwrap_or(0);
343        slots.push(DimmSlot {
344            size_mb,
345            mem_type: smbios_memory_type(type_code),
346            speed_mt: if speed_mt > 0 { Some(speed_mt) } else { None },
347        });
348    }
349    format_dimm_slots(&slots)
350}
351
352/// Maps SMBIOS memory type codes (from `Win32_PhysicalMemory.SMBIOSMemoryType`) to strings.
353#[cfg(target_os = "windows")]
354fn smbios_memory_type(code: u16) -> String {
355    match code {
356        20 => "DDR".to_string(),
357        21 => "DDR2".to_string(),
358        24 => "DDR3".to_string(),
359        26 => "DDR4".to_string(),
360        27 => "LPDDR".to_string(),
361        28 => "LPDDR2".to_string(),
362        29 => "LPDDR3".to_string(),
363        30 => "LPDDR4".to_string(),
364        34 => "DDR5".to_string(),
365        35 => "LPDDR5".to_string(),
366        _ => String::new(),
367    }
368}
369
370/// Splits one PowerShell `ConvertTo-Csv` line into unquoted fields.
371///
372/// `ConvertTo-Csv` wraps every value in double quotes: `"val1","val2",...`
373#[cfg(target_os = "windows")]
374fn unquoted_csv_fields(line: &str) -> Vec<String> {
375    let line = line.trim().trim_matches('"');
376    line.split("\",\"").map(|s| s.to_string()).collect()
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[cfg(target_os = "linux")]
384    #[test]
385    fn test_parse_dmidecode_two_slots() {
386        let input = r#"
387Memory Device
388        Size: 8 GB
389        Type: DDR5
390        Speed: 4800 MT/s
391
392Memory Device
393        Size: 8 GB
394        Type: DDR5
395        Speed: 4800 MT/s
396"#;
397        let slots = parse_dmidecode_type17(input);
398        assert_eq!(slots.len(), 2);
399        assert_eq!(slots[0].size_mb, 8192);
400        assert_eq!(slots[0].mem_type, "DDR5");
401        assert_eq!(slots[0].speed_mt, Some(4800));
402
403        let summary = format_dimm_slots(&slots).unwrap();
404        assert_eq!(summary, "2× 8 GB DDR5 4800 MT/s");
405    }
406
407    #[cfg(target_os = "linux")]
408    #[test]
409    fn test_parse_dmidecode_gib_units() {
410        // dmidecode reports GiB on some systems (e.g. LPDDR5 laptops)
411        let input = r#"
412Memory Device
413    Size: 2 GiB
414    Type: LPDDR5
415    Speed: 6400 MT/s
416
417Memory Device
418    Size: 2 GiB
419    Type: LPDDR5
420    Speed: 6400 MT/s
421"#;
422        let slots = parse_dmidecode_type17(input);
423        assert_eq!(slots.len(), 2);
424        assert_eq!(slots[0].size_mb, 2048);
425        assert_eq!(slots[0].mem_type, "LPDDR5");
426        assert_eq!(slots[0].speed_mt, Some(6400));
427
428        let summary = format_dimm_slots(&slots).unwrap();
429        assert_eq!(summary, "2× 2 GB LPDDR5 6400 MT/s");
430    }
431
432    #[cfg(target_os = "linux")]
433    #[test]
434    fn test_parse_dmidecode_empty_slot() {
435        let input = r#"
436Memory Device
437        Size: No Module Installed
438        Type: Unknown
439
440Memory Device
441        Size: 16 GB
442        Type: DDR4
443        Speed: 3200 MT/s
444"#;
445        let slots = parse_dmidecode_type17(input);
446        assert_eq!(slots.len(), 1);
447        assert_eq!(slots[0].size_mb, 16384);
448        let summary = format_dimm_slots(&slots).unwrap();
449        assert_eq!(summary, "16 GB DDR4 3200 MT/s");
450    }
451
452    #[cfg(target_os = "linux")]
453    #[test]
454    fn test_parse_dmidecode_mixed_sizes() {
455        let input = r#"
456Memory Device
457        Size: 16 GB
458        Type: DDR5
459        Speed: 5600 MT/s
460
461Memory Device
462        Size: 32 GB
463        Type: DDR5
464        Speed: 5600 MT/s
465"#;
466        let slots = parse_dmidecode_type17(input);
467        assert_eq!(slots.len(), 2);
468        let summary = format_dimm_slots(&slots).unwrap();
469        // Two different sizes → two groups
470        assert!(summary.contains("16 GB DDR5 5600 MT/s"));
471        assert!(summary.contains("32 GB DDR5 5600 MT/s"));
472    }
473
474    #[cfg(target_os = "macos")]
475    #[test]
476    fn test_parse_system_profiler_apple_silicon() {
477        let input = r#"
478Memory:
479
480      Type: LPDDR5
481      Speed: 6400 MT/s
482      Size: 16 GB
483"#;
484        let result = parse_system_profiler_memory(input);
485        assert_eq!(result, Some("16 GB LPDDR5 6400 MT/s".to_string()));
486    }
487
488    #[cfg(target_os = "macos")]
489    #[test]
490    fn test_parse_system_profiler_multi_slot() {
491        let input = r#"
492Memory:
493
494    BANK 0/DIMM0:
495
496      Size: 16 GB
497      Type: DDR5
498      Speed: 4800 MT/s
499
500    BANK 1/DIMM0:
501
502      Size: 16 GB
503      Type: DDR5
504      Speed: 4800 MT/s
505"#;
506        let result = parse_system_profiler_memory(input);
507        assert_eq!(result, Some("2× 16 GB DDR5 4800 MT/s".to_string()));
508    }
509
510    #[cfg(target_os = "windows")]
511    #[test]
512    fn test_parse_win32_physical_memory_ddr4() {
513        let csv = r#""Capacity","SMBIOSMemoryType","Speed"
514"8589934592","26","3200"
515"8589934592","26","3200"
516"#;
517        let result = parse_win32_physical_memory(csv);
518        assert_eq!(result, Some("2× 8 GB DDR4 3200 MT/s".to_string()));
519    }
520
521    #[cfg(target_os = "windows")]
522    #[test]
523    fn test_parse_win32_physical_memory_ddr5() {
524        let csv = r#""Capacity","SMBIOSMemoryType","Speed"
525"17179869184","34","4800"
526"17179869184","34","4800"
527"#;
528        let result = parse_win32_physical_memory(csv);
529        assert_eq!(result, Some("2× 16 GB DDR5 4800 MT/s".to_string()));
530    }
531
532    #[cfg(target_os = "windows")]
533    #[test]
534    fn test_parse_win32_physical_memory_unknown_type() {
535        // SMBIOSMemoryType=0 → no type label emitted
536        let csv = r#""Capacity","SMBIOSMemoryType","Speed"
537"34359738368","0","0"
538"#;
539        let result = parse_win32_physical_memory(csv);
540        assert_eq!(result, Some("32 GB".to_string()));
541    }
542
543    #[cfg(target_os = "windows")]
544    #[test]
545    fn test_parse_win32_physical_memory_mixed_slots() {
546        let csv = r#""Capacity","SMBIOSMemoryType","Speed"
547"8589934592","26","3200"
548"16777216","26","3200"
549"#;
550        // 8 GB + 16 MB (different sizes → two groups)
551        let result = parse_win32_physical_memory(csv);
552        let s = result.unwrap();
553        assert!(s.contains("8 GB DDR4 3200 MT/s"));
554        assert!(s.contains("16 MB DDR4 3200 MT/s"));
555    }
556}