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(not(any(target_os = "linux", target_os = "macos")))]
14    return None;
15}
16
17/// Represents one DIMM slot as parsed from DMI type-17 output.
18#[cfg(any(target_os = "linux", target_os = "macos"))]
19#[derive(Debug, PartialEq)]
20pub struct DimmSlot {
21    pub size_mb: u64,
22    pub mem_type: String,
23    pub speed_mt: Option<u64>,
24}
25
26/// Parses `dmidecode --type 17` text output into a list of populated DIMM slots.
27#[cfg(target_os = "linux")]
28pub fn parse_dmidecode_type17(output: &str) -> Vec<DimmSlot> {
29    let mut slots = Vec::new();
30    let mut size_mb: Option<u64> = None;
31    let mut mem_type = String::new();
32    let mut speed_mt: Option<u64> = None;
33
34    for line in output.lines() {
35        let trimmed = line.trim();
36
37        if trimmed == "Memory Device" {
38            // Flush previous slot
39            if let Some(mb) = size_mb.take() {
40                if mb > 0 {
41                    slots.push(DimmSlot {
42                        size_mb: mb,
43                        mem_type: mem_type.clone(),
44                        speed_mt,
45                    });
46                }
47            }
48            mem_type.clear();
49            speed_mt = None;
50        } else if let Some(rest) = trimmed.strip_prefix("Size:") {
51            let rest = rest.trim();
52            if rest.contains("No Module") || rest == "Unknown" {
53                size_mb = Some(0);
54            } else if let Some(n) = rest
55                .strip_suffix(" GiB")
56                .or_else(|| rest.strip_suffix(" GB"))
57            {
58                size_mb = n.trim().parse::<u64>().ok().map(|g| g * 1024);
59            } else if let Some(n) = rest
60                .strip_suffix(" MiB")
61                .or_else(|| rest.strip_suffix(" MB"))
62            {
63                size_mb = n.trim().parse::<u64>().ok();
64            }
65        } else if let Some(rest) = trimmed.strip_prefix("Type:") {
66            let t = rest.trim();
67            if t != "Unknown" && !t.is_empty() {
68                mem_type = t.to_string();
69            }
70        } else if let Some(rest) = trimmed.strip_prefix("Speed:") {
71            // e.g. "4800 MT/s" or "Unknown"
72            let rest = rest.trim();
73            if let Some(mt_str) = rest.strip_suffix(" MT/s") {
74                speed_mt = mt_str.trim().parse::<u64>().ok();
75            }
76        }
77    }
78
79    // Flush last slot
80    if let Some(mb) = size_mb {
81        if mb > 0 {
82            slots.push(DimmSlot {
83                size_mb: mb,
84                mem_type,
85                speed_mt,
86            });
87        }
88    }
89
90    slots
91}
92
93/// Formats a list of DIMM slots into a human-readable summary string.
94#[cfg(any(target_os = "linux", target_os = "macos"))]
95pub fn format_dimm_slots(slots: &[DimmSlot]) -> Option<String> {
96    if slots.is_empty() {
97        return None;
98    }
99
100    // Group identical slots (same size + type + speed)
101    #[derive(PartialEq, Eq, Hash)]
102    struct Key {
103        size_mb: u64,
104        mem_type: String,
105        speed_mt: Option<u64>,
106    }
107
108    let mut groups: Vec<(Key, usize)> = Vec::new();
109    for slot in slots {
110        let key = Key {
111            size_mb: slot.size_mb,
112            mem_type: slot.mem_type.clone(),
113            speed_mt: slot.speed_mt,
114        };
115        if let Some(entry) = groups.iter_mut().find(|(k, _)| k == &key) {
116            entry.1 += 1;
117        } else {
118            groups.push((key, 1));
119        }
120    }
121
122    let parts: Vec<String> = groups
123        .iter()
124        .map(|(key, count)| {
125            let size_str = if key.size_mb >= 1024 {
126                format!("{} GB", key.size_mb / 1024)
127            } else {
128                format!("{} MB", key.size_mb)
129            };
130
131            let mut s = if *count > 1 {
132                format!("{}× {}", count, size_str)
133            } else {
134                size_str
135            };
136
137            if !key.mem_type.is_empty() {
138                s.push(' ');
139                s.push_str(&key.mem_type);
140            }
141
142            if let Some(mt) = key.speed_mt {
143                s.push_str(&format!(" {} MT/s", mt));
144            }
145
146            s
147        })
148        .collect();
149
150    Some(parts.join(", "))
151}
152
153#[cfg(target_os = "linux")]
154fn detect_linux() -> Option<String> {
155    // Try name-only first (works when /usr/bin is in PATH), then known absolute paths.
156    let candidates = ["dmidecode", "/usr/bin/dmidecode", "/usr/sbin/dmidecode"];
157    for cmd in candidates {
158        let Ok(output) = std::process::Command::new(cmd)
159            .args(["--type", "17"])
160            .output()
161        else {
162            continue;
163        };
164        if !output.status.success() {
165            continue;
166        }
167        let text = String::from_utf8_lossy(&output.stdout);
168        let slots = parse_dmidecode_type17(&text);
169        return format_dimm_slots(&slots);
170    }
171    None
172}
173
174#[cfg(target_os = "macos")]
175fn detect_macos() -> Option<String> {
176    let output = std::process::Command::new("system_profiler")
177        .args(["SPMemoryDataType", "-detailLevel", "basic"])
178        .output()
179        .ok()?;
180
181    if !output.status.success() {
182        return None;
183    }
184
185    let text = String::from_utf8_lossy(&output.stdout);
186    parse_system_profiler_memory(&text)
187}
188
189/// Parses `system_profiler SPMemoryDataType` text output into a summary string.
190#[cfg(target_os = "macos")]
191pub fn parse_system_profiler_memory(text: &str) -> Option<String> {
192    // Example output:
193    //   Memory:
194    //     Type: LPDDR5
195    //     Speed: 6400 MT/s
196    //     Manufacturers: SK Hynix
197    //     Size: 16 GB
198    // Or for multi-slot Macs:
199    //   BANK 0/DIMM0:
200    //     Size: 16 GB
201    //     Type: DDR5
202    //     Speed: 4800 MT/s
203
204    let mut slots: Vec<DimmSlot> = Vec::new();
205    let mut current_size_mb: Option<u64> = None;
206    let mut current_type = String::new();
207    let mut current_speed: Option<u64> = None;
208    let mut in_slot = false;
209
210    for line in text.lines() {
211        let trimmed = line.trim();
212
213        // Slot/bank header (indented section headers ending with ':')
214        if (trimmed.contains("DIMM") || trimmed.contains("BANK") || trimmed.contains("Slot"))
215            && trimmed.ends_with(':')
216        {
217            if in_slot {
218                if let Some(mb) = current_size_mb.take() {
219                    if mb > 0 {
220                        slots.push(DimmSlot {
221                            size_mb: mb,
222                            mem_type: current_type.clone(),
223                            speed_mt: current_speed,
224                        });
225                    }
226                }
227                current_type.clear();
228                current_speed = None;
229            }
230            in_slot = true;
231            continue;
232        }
233
234        // "Size:" is used on physical Macs; VMs report the total as "Memory:" instead
235        let size_rest = trimmed.strip_prefix("Size:").or_else(|| {
236            trimmed
237                .strip_prefix("Memory:")
238                .filter(|s| !s.trim().is_empty())
239        });
240        if let Some(rest) = size_rest {
241            let rest = rest.trim();
242            if rest.contains("Empty") || rest == "Unknown" {
243                current_size_mb = Some(0);
244            } else if let Some(gb_str) = rest.strip_suffix(" GB") {
245                current_size_mb = gb_str.trim().parse::<u64>().ok().map(|g| g * 1024);
246            } else if let Some(mb_str) = rest.strip_suffix(" MB") {
247                current_size_mb = mb_str.trim().parse::<u64>().ok();
248            }
249        } else if let Some(rest) = trimmed.strip_prefix("Type:") {
250            let t = rest.trim();
251            if t != "Unknown" && !t.is_empty() {
252                current_type = t.to_string();
253            }
254        } else if let Some(rest) = trimmed.strip_prefix("Speed:") {
255            let rest = rest.trim();
256            if let Some(mt_str) = rest.strip_suffix(" MT/s") {
257                current_speed = mt_str.trim().parse::<u64>().ok();
258            } else if let Some(mhz_str) = rest.strip_suffix(" MHz") {
259                // Older Macs report MHz
260                current_speed = mhz_str.trim().parse::<u64>().ok().map(|mhz| mhz * 2);
261            }
262        }
263    }
264
265    // Flush last slot
266    if let Some(mb) = current_size_mb {
267        if mb > 0 {
268            slots.push(DimmSlot {
269                size_mb: mb,
270                mem_type: current_type,
271                speed_mt: current_speed,
272            });
273        }
274    }
275
276    // Apple Silicon unified memory: system_profiler often reports a single "Size" at the top level
277    // without slot headers — we'll have one slot from the flush above.
278
279    format_dimm_slots(&slots)
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[cfg(target_os = "linux")]
287    #[test]
288    fn test_parse_dmidecode_two_slots() {
289        let input = r#"
290Memory Device
291        Size: 8 GB
292        Type: DDR5
293        Speed: 4800 MT/s
294
295Memory Device
296        Size: 8 GB
297        Type: DDR5
298        Speed: 4800 MT/s
299"#;
300        let slots = parse_dmidecode_type17(input);
301        assert_eq!(slots.len(), 2);
302        assert_eq!(slots[0].size_mb, 8192);
303        assert_eq!(slots[0].mem_type, "DDR5");
304        assert_eq!(slots[0].speed_mt, Some(4800));
305
306        let summary = format_dimm_slots(&slots).unwrap();
307        assert_eq!(summary, "2× 8 GB DDR5 4800 MT/s");
308    }
309
310    #[cfg(target_os = "linux")]
311    #[test]
312    fn test_parse_dmidecode_gib_units() {
313        // dmidecode reports GiB on some systems (e.g. LPDDR5 laptops)
314        let input = r#"
315Memory Device
316    Size: 2 GiB
317    Type: LPDDR5
318    Speed: 6400 MT/s
319
320Memory Device
321    Size: 2 GiB
322    Type: LPDDR5
323    Speed: 6400 MT/s
324"#;
325        let slots = parse_dmidecode_type17(input);
326        assert_eq!(slots.len(), 2);
327        assert_eq!(slots[0].size_mb, 2048);
328        assert_eq!(slots[0].mem_type, "LPDDR5");
329        assert_eq!(slots[0].speed_mt, Some(6400));
330
331        let summary = format_dimm_slots(&slots).unwrap();
332        assert_eq!(summary, "2× 2 GB LPDDR5 6400 MT/s");
333    }
334
335    #[cfg(target_os = "linux")]
336    #[test]
337    fn test_parse_dmidecode_empty_slot() {
338        let input = r#"
339Memory Device
340        Size: No Module Installed
341        Type: Unknown
342
343Memory Device
344        Size: 16 GB
345        Type: DDR4
346        Speed: 3200 MT/s
347"#;
348        let slots = parse_dmidecode_type17(input);
349        assert_eq!(slots.len(), 1);
350        assert_eq!(slots[0].size_mb, 16384);
351        let summary = format_dimm_slots(&slots).unwrap();
352        assert_eq!(summary, "16 GB DDR4 3200 MT/s");
353    }
354
355    #[cfg(target_os = "linux")]
356    #[test]
357    fn test_parse_dmidecode_mixed_sizes() {
358        let input = r#"
359Memory Device
360        Size: 16 GB
361        Type: DDR5
362        Speed: 5600 MT/s
363
364Memory Device
365        Size: 32 GB
366        Type: DDR5
367        Speed: 5600 MT/s
368"#;
369        let slots = parse_dmidecode_type17(input);
370        assert_eq!(slots.len(), 2);
371        let summary = format_dimm_slots(&slots).unwrap();
372        // Two different sizes → two groups
373        assert!(summary.contains("16 GB DDR5 5600 MT/s"));
374        assert!(summary.contains("32 GB DDR5 5600 MT/s"));
375    }
376
377    #[cfg(target_os = "macos")]
378    #[test]
379    fn test_parse_system_profiler_apple_silicon() {
380        let input = r#"
381Memory:
382
383      Type: LPDDR5
384      Speed: 6400 MT/s
385      Size: 16 GB
386"#;
387        let result = parse_system_profiler_memory(input);
388        assert_eq!(result, Some("16 GB LPDDR5 6400 MT/s".to_string()));
389    }
390
391    #[cfg(target_os = "macos")]
392    #[test]
393    fn test_parse_system_profiler_multi_slot() {
394        let input = r#"
395Memory:
396
397    BANK 0/DIMM0:
398
399      Size: 16 GB
400      Type: DDR5
401      Speed: 4800 MT/s
402
403    BANK 1/DIMM0:
404
405      Size: 16 GB
406      Type: DDR5
407      Speed: 4800 MT/s
408"#;
409        let result = parse_system_profiler_memory(input);
410        assert_eq!(result, Some("2× 16 GB DDR5 4800 MT/s".to_string()));
411    }
412}