Coverage Report

Created: 2026-07-21 05:33

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
src/metal.rs
Line
Count
Source
1
// SPDX-License-Identifier: Apache-2.0
2
//! Apple/macOS detection via `system_profiler SPDisplaysDataType` and `sysctl
3
//! hw.memsize`. No Metal framework linkage required. Apple Silicon uses a
4
//! unified memory architecture (no dedicated VRAM), so `total_bytes` falls
5
//! back to total physical memory.
6
7
/// Map a vendor id as printed by `system_profiler` (e.g. `0x106b`) to a
8
/// [`Vendor`](crate::Vendor).
9
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
10
10
fn vendor_from_id(id: &str) -> crate::Vendor {
11
    use crate::Vendor;
12
10
    match id.trim().to_ascii_lowercase().as_str() {
13
10
        "0x106b" => 
Vendor::Apple3
,
14
7
        "0x1002" => 
Vendor::Amd3
,
15
4
        "0x10de" => 
Vendor::Nvidia1
,
16
3
        "0x8086" => 
Vendor::Intel2
,
17
1
        _ => Vendor::Unknown,
18
    }
19
10
}
20
21
/// Parse a `system_profiler` VRAM value like `"8 GB"` or `"1536 MB"` into bytes
22
/// (binary units).
23
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
24
12
fn parse_vram(value: &str) -> Option<u64> {
25
12
    let (
num9
,
unit9
) = value.trim().split_once(' ')
?3
;
26
9
    let 
amount8
:
u648
= num.trim().parse().ok()
?1
;
27
8
    let 
mult7
:
u647
= match unit.trim().to_ascii_uppercase().as_str() {
28
8
        "GB" => 
1024 * 1024 * 10243
,
29
5
        "MB" => 
1024 * 10243
,
30
2
        "KB" => 
10241
,
31
1
        _ => return None,
32
    };
33
7
    Some(amount * mult)
34
12
}
35
36
/// Parse `sysctl -n hw.memsize` output (a decimal byte count).
37
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
38
4
fn parse_memsize(content: &str) -> Option<u64> {
39
4
    content.trim().parse().ok()
40
4
}
41
42
/// Parse plain-text `system_profiler SPDisplaysDataType` output into one
43
/// [`GpuInfo`](crate::GpuInfo) per "Chipset Model:" block. `total_bytes` is `0`
44
/// when no VRAM line is present (Apple Silicon unified memory); callers
45
/// backfill it from physical memory.
46
#[allow(dead_code)] // used on macOS + in tests; unused on other targets
47
8
fn parse_system_profiler(text: &str) -> Vec<crate::GpuInfo> {
48
    use crate::{GpuInfo, Vendor};
49
50
8
    let mut gpus: Vec<GpuInfo> = Vec::new();
51
29
    for raw in 
text8
.
lines8
() {
52
29
        let line = raw.trim();
53
29
        if let Some(
name7
) = line.strip_prefix("Chipset Model:") {
54
7
            gpus.push(GpuInfo {
55
7
                name: name.trim().to_string(),
56
7
                vendor: Vendor::Apple,
57
7
                total_bytes: 0,
58
7
                free_bytes: None,
59
7
                used_bytes: None,
60
7
            });
61
22
        } else if let Some(
gpu16
) = gpus.last_mut() {
62
16
            if let Some(
v7
) = line.strip_prefix("Vendor:") {
63
                // e.g. "Apple (0x106b)" — pull out the parenthesized id.
64
7
                if let Some(
id6
) = v.split('(').nth(1).and_then(|s|
s.split(')')6
.
next6
()) {
65
6
                    gpu.vendor = vendor_from_id(id);
66
6
                
}1
67
9
            } else if let Some(
v5
) = line
68
9
                .strip_prefix("VRAM (Total):")
69
9
                .or_else(|| 
line6
.
strip_prefix6
("VRAM (Dynamic, Max):"))
70
5
                && let Some(
bytes4
) = parse_vram(v)
71
4
            {
72
4
                gpu.total_bytes = bytes;
73
5
            }
74
6
        }
75
    }
76
8
    gpus
77
8
}
78
79
#[cfg(target_os = "macos")]
80
pub(crate) fn detect() -> Vec<crate::GpuInfo> {
81
    use crate::{GpuInfo, Vendor};
82
83
    let mut gpus = std::process::Command::new("system_profiler")
84
        .arg("SPDisplaysDataType")
85
        .output()
86
        .ok()
87
        .filter(|o| o.status.success())
88
        .map(|o| parse_system_profiler(&String::from_utf8_lossy(&o.stdout)))
89
        .unwrap_or_default();
90
91
    // Apple Silicon reports no VRAM line — backfill from physical memory.
92
    let memsize = sysctl_memsize();
93
    for gpu in &mut gpus {
94
        if gpu.total_bytes == 0
95
            && let Some(mem) = memsize
96
        {
97
            gpu.total_bytes = mem;
98
        }
99
    }
100
    if gpus.is_empty()
101
        && let Some(mem) = memsize
102
    {
103
        gpus.push(GpuInfo {
104
            name: "Apple GPU".to_string(),
105
            vendor: Vendor::Apple,
106
            total_bytes: mem,
107
            free_bytes: None,
108
            used_bytes: None,
109
        });
110
    }
111
    gpus
112
}
113
114
#[cfg(target_os = "macos")]
115
fn sysctl_memsize() -> Option<u64> {
116
    let output = std::process::Command::new("sysctl")
117
        .args(["-n", "hw.memsize"])
118
        .output()
119
        .ok()?;
120
    output
121
        .status
122
        .success()
123
        .then(|| parse_memsize(&String::from_utf8_lossy(&output.stdout)))
124
        .flatten()
125
}
126
127
#[cfg(not(target_os = "macos"))]
128
215
pub(crate) fn detect() -> Vec<crate::GpuInfo> {
129
215
    Vec::new()
130
215
}
131
132
#[cfg(test)]
133
mod tests {
134
    use super::*;
135
    use crate::Vendor;
136
137
    #[test]
138
1
    fn apple_silicon_block_has_no_vram() {
139
1
        let text = "Graphics/Displays:\n\n    Apple M2 Pro:\n\n      Chipset Model: Apple M2 Pro\n      Type: GPU\n      Vendor: Apple (0x106b)\n      Metal Support: Metal 3\n";
140
1
        let gpus = parse_system_profiler(text);
141
1
        assert_eq!(gpus.len(), 1);
142
1
        assert_eq!(gpus[0].name, "Apple M2 Pro");
143
1
        assert_eq!(gpus[0].vendor, Vendor::Apple);
144
1
        assert_eq!(
145
1
            gpus[0].total_bytes, 0,
146
            "unified memory; backfilled in detect()"
147
        );
148
1
    }
149
150
    #[test]
151
1
    fn intel_mac_discrete_gpu_reports_vram() {
152
1
        let text = "      Chipset Model: AMD Radeon Pro 5500M\n      Type: GPU\n      Bus: PCIe\n      VRAM (Total): 8 GB\n      Vendor: AMD (0x1002)\n";
153
1
        let gpus = parse_system_profiler(text);
154
1
        assert_eq!(gpus.len(), 1);
155
1
        assert_eq!(gpus[0].vendor, Vendor::Amd);
156
1
        assert_eq!(gpus[0].total_bytes, 8 * 1024 * 1024 * 1024);
157
1
    }
158
159
    #[test]
160
1
    fn dynamic_vram_line_is_parsed() {
161
1
        let text = "      Chipset Model: Intel Iris Pro\n      VRAM (Dynamic, Max): 1536 MB\n      Vendor: Intel (0x8086)\n";
162
1
        let gpus = parse_system_profiler(text);
163
1
        assert_eq!(gpus[0].vendor, Vendor::Intel);
164
1
        assert_eq!(gpus[0].total_bytes, 1536 * 1024 * 1024);
165
1
    }
166
167
    #[test]
168
1
    fn vram_and_memsize_parsers() {
169
1
        assert_eq!(parse_vram("8 GB"), Some(8 * 1024 * 1024 * 1024));
170
1
        assert_eq!(parse_vram("1536 MB"), Some(1536 * 1024 * 1024));
171
1
        assert_eq!(parse_vram("weird"), None);
172
1
        assert_eq!(parse_memsize("17179869184\n"), Some(17_179_869_184));
173
1
    }
174
175
    #[test]
176
1
    fn vendor_id_mapping() {
177
1
        assert_eq!(vendor_from_id("0x106b"), Vendor::Apple);
178
1
        assert_eq!(vendor_from_id("0x10DE"), Vendor::Nvidia);
179
1
        assert_eq!(vendor_from_id("0x1002"), Vendor::Amd);
180
1
        assert_eq!(vendor_from_id("0xbeef"), Vendor::Unknown);
181
1
    }
182
183
    #[test]
184
1
    fn parses_multiple_gpu_blocks() {
185
1
        let text = "      Chipset Model: AMD Radeon Pro 5500M\n      VRAM (Total): 8 GB\n      Vendor: AMD (0x1002)\n      Chipset Model: Intel UHD Graphics 630\n      VRAM (Dynamic, Max): 1536 MB\n      Vendor: Intel (0x8086)\n";
186
1
        let gpus = parse_system_profiler(text);
187
1
        assert_eq!(gpus.len(), 2);
188
1
        assert_eq!(gpus[0].vendor, Vendor::Amd);
189
1
        assert_eq!(gpus[0].total_bytes, 8 * 1024 * 1024 * 1024);
190
1
        assert_eq!(gpus[1].vendor, Vendor::Intel);
191
1
        assert_eq!(gpus[1].total_bytes, 1536 * 1024 * 1024);
192
1
    }
193
194
    #[test]
195
1
    fn empty_output_yields_no_gpus() {
196
1
        assert!(parse_system_profiler("").is_empty());
197
        // Lines before any "Chipset Model:" have no GPU to attach to.
198
1
        assert!(parse_system_profiler("Graphics/Displays:\n      VRAM (Total): 8 GB\n").is_empty());
199
1
    }
200
201
    #[test]
202
1
    fn vendor_line_without_parens_keeps_default() {
203
1
        let text = "      Chipset Model: Mystery GPU\n      Vendor: sieve\n";
204
1
        let gpus = parse_system_profiler(text);
205
1
        assert_eq!(gpus.len(), 1);
206
        // No "(id)" to parse, so the Apple default placed at block start stands.
207
1
        assert_eq!(gpus[0].vendor, Vendor::Apple);
208
1
    }
209
210
    #[test]
211
1
    fn malformed_vram_leaves_total_at_zero() {
212
1
        let text =
213
1
            "      Chipset Model: Broken\n      VRAM (Total): lots\n      Vendor: Apple (0x106b)\n";
214
1
        let gpus = parse_system_profiler(text);
215
1
        assert_eq!(gpus[0].total_bytes, 0);
216
1
    }
217
218
    #[test]
219
1
    fn parse_vram_rejects_unknown_units_and_bad_numbers() {
220
1
        assert_eq!(parse_vram("8 TB"), None);
221
1
        assert_eq!(parse_vram("8"), None);
222
1
        assert_eq!(parse_vram("eight GB"), None);
223
1
        assert_eq!(
224
1
            parse_vram("512 kb"),
225
1
            Some(512 * 1024),
226
            "unit is case-insensitive"
227
        );
228
1
    }
229
230
    #[test]
231
1
    fn parse_memsize_rejects_non_numeric() {
232
1
        assert_eq!(parse_memsize("nope"), None);
233
1
        assert_eq!(parse_memsize(""), None);
234
1
        assert_eq!(parse_memsize("0"), Some(0));
235
1
    }
236
}