Skip to main content

retch_sysinfo/
display.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Display detection and EDID parsing.
5//!
6//! This module provides cross-platform detection of connected displays,
7//! including resolution, refresh rate, and monitor name/serial extraction
8//! from raw EDID blobs.
9//!
10//! ## Platform Support
11//!
12//! - **Linux**: Reads natively from `/sys/class/drm` (status, modes, and raw EDID
13//!   bytes). Falls back to `xrandr --current` when no sysfs displays are found.
14//! - **macOS**: Parses `system_profiler SPDisplaysDataType` output.
15//! - **Windows**: Calls `EnumDisplayDevicesW` + `EnumDisplaySettingsW` via user32.dll FFI.
16
17/// Parse the monitor's human-readable name from a raw EDID binary blob.
18///
19/// Searches the four 18-byte descriptor blocks (at EDID offsets 54, 72, 90,
20/// and 108) for a Monitor Name Descriptor (tag `0xFC`) and returns the name
21/// string, or `None` if no such descriptor is found or the EDID is too short.
22#[allow(dead_code)]
23pub fn parse_monitor_name_from_edid(edid: &[u8]) -> Option<String> {
24    if edid.len() < 128 {
25        return None;
26    }
27    let offsets = [54, 72, 90, 108];
28    for &offset in &offsets {
29        if offset + 18 <= edid.len() {
30            let block = &edid[offset..offset + 18];
31            if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFC {
32                let name_bytes = &block[4..17];
33                let name = String::from_utf8_lossy(name_bytes);
34                let cleaned = name.trim().replace('\0', "").to_string();
35                if !cleaned.is_empty() {
36                    return Some(cleaned);
37                }
38            }
39        }
40    }
41    None
42}
43
44/// Parse the refresh rate (in Hz) from the first Detailed Timing Descriptor
45/// (DTD) block in a raw EDID binary blob.
46///
47/// Reads the pixel clock, horizontal/vertical active and blanking values from
48/// bytes 54–71 of the EDID and computes the refresh rate as:
49/// `pixel_clock_hz / (h_total * v_total)`.
50///
51/// Returns `None` if the EDID is too short or the pixel clock is zero.
52#[allow(dead_code)]
53pub fn parse_refresh_rate_from_edid(edid: &[u8]) -> Option<f64> {
54    if edid.len() < 72 {
55        return None;
56    }
57    let block = &edid[54..72];
58    let pixel_clock = ((block[1] as u32) << 8) | (block[0] as u32);
59    if pixel_clock == 0 {
60        return None;
61    }
62    let pixel_clock_hz = pixel_clock * 10_000;
63    let h_active = (block[2] as u32) | (((block[4] as u32) & 0xF0) << 4);
64    let h_blanking = (block[3] as u32) | (((block[4] as u32) & 0x0F) << 8);
65    let v_active = (block[5] as u32) | (((block[7] as u32) & 0xF0) << 4);
66    let v_blanking = (block[6] as u32) | (((block[7] as u32) & 0x0F) << 8);
67
68    let h_total = h_active + h_blanking;
69    let v_total = v_active + v_blanking;
70    if h_total == 0 || v_total == 0 {
71        return None;
72    }
73
74    let refresh = (pixel_clock_hz as f64) / ((h_total * v_total) as f64);
75    Some((refresh * 100.0).round() / 100.0)
76}
77
78/// Format a refresh rate value for display.
79///
80/// Integers (e.g. 60.0) are rendered without decimals; non-integer values
81/// (e.g. 59.94) are rounded to two decimal places.
82#[allow(dead_code)]
83pub fn format_refresh_rate(refresh: f64) -> String {
84    if (refresh - refresh.round()).abs() < 0.01 {
85        format!("{:.0}", refresh)
86    } else {
87        format!("{:.2}", refresh)
88    }
89}
90
91/// Parse the serial number from a raw EDID binary blob.
92///
93/// First searches for an ASCII Serial Number Descriptor (tag `0xFF`) in the
94/// four 18-byte descriptor blocks at offsets 54, 72, 90, and 108. If not
95/// found, falls back to the 32-bit numeric serial number at EDID bytes 12–15.
96///
97/// Returns `None` if no serial number is found or the EDID is too short.
98#[allow(dead_code)]
99pub fn parse_serial_number_from_edid(edid: &[u8]) -> Option<String> {
100    if edid.len() < 128 {
101        return None;
102    }
103    // 1. Try finding ASCII Serial Number descriptor block (tag 0xFF)
104    let offsets = [54, 72, 90, 108];
105    for &offset in &offsets {
106        if offset + 18 <= edid.len() {
107            let block = &edid[offset..offset + 18];
108            if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFF {
109                let serial_bytes = &block[4..17];
110                let serial = String::from_utf8_lossy(serial_bytes);
111                let cleaned = serial.trim().replace('\0', "").to_string();
112                if !cleaned.is_empty() {
113                    return Some(cleaned);
114                }
115            }
116        }
117    }
118
119    // 2. Fallback to the 32-bit numeric serial number at offset 12-15
120    let serial_num = ((edid[15] as u32) << 24)
121        | ((edid[14] as u32) << 16)
122        | ((edid[13] as u32) << 8)
123        | (edid[12] as u32);
124    if serial_num != 0 && serial_num != 0xFFFFFFFF {
125        return Some(serial_num.to_string());
126    }
127
128    None
129}
130
131/// Look up the monitor name for a given DRM connector port name by reading
132/// the EDID from sysfs (`/sys/class/drm/<entry>/edid`).
133///
134/// Iterates `/sys/class/drm` for entries whose name ends with `port`, reads
135/// the EDID bytes, and extracts the monitor name via [`parse_monitor_name_from_edid`].
136/// Returns `None` if not found on Linux or not compiled for Linux.
137#[allow(dead_code)]
138pub fn get_monitor_name_for_port(port: &str) -> Option<String> {
139    if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
140        for entry in entries.filter_map(|e| e.ok()) {
141            let name = entry.file_name().to_string_lossy().to_string();
142            if name.ends_with(port) {
143                let edid_path = entry.path().join("edid");
144                if edid_path.exists() {
145                    if let Ok(edid_bytes) = std::fs::read(&edid_path) {
146                        if let Some(monitor_name) = parse_monitor_name_from_edid(&edid_bytes) {
147                            return Some(monitor_name);
148                        }
149                    }
150                }
151            }
152        }
153    }
154    None
155}
156
157/// Detect connected displays and return a list of human-readable strings
158/// describing each display (name, resolution, and refresh rate).
159///
160/// Platform-specific implementations:
161/// - **Linux**: Reads `/sys/class/drm` natively; falls back to `xrandr --current`.
162/// - **macOS**: Parses `system_profiler SPDisplaysDataType` output.
163/// - **Windows**: Calls `EnumDisplayDevicesW` + `EnumDisplaySettingsW` via user32.dll FFI.
164pub fn detect_displays() -> Vec<String> {
165    #[cfg(target_os = "macos")]
166    {
167        crate::macos_ffi::get_displays()
168    }
169
170    #[cfg(target_os = "windows")]
171    {
172        // Enumerate displays via Win32 EnumDisplayDevicesW + EnumDisplaySettingsW (user32.dll).
173        // This avoids spawning wmic and works on all modern Windows versions.
174        #[repr(C)]
175        struct DisplayDevice {
176            cb: u32,
177            device_name: [u16; 32],
178            device_string: [u16; 128],
179            state_flags: u32,
180            device_id: [u16; 128],
181            device_key: [u16; 128],
182        }
183
184        // Full DEVMODEW layout (220 bytes). Offsets must match winuser.h exactly
185        // to avoid stack corruption when EnumDisplaySettingsW writes into this.
186        #[repr(C)]
187        struct DevMode {
188            device_name: [u16; 32], // offset   0, 64 bytes
189            spec_version: u16,      // offset  64
190            driver_version: u16,    // offset  66
191            size: u16,              // offset  68
192            driver_extra: u16,      // offset  70
193            fields: u32,            // offset  72
194            // display union: dmPosition(8) + dmDisplayOrientation(4) + dmDisplayFixedOutput(4)
195            position_x: i32,           // offset  76
196            position_y: i32,           // offset  80
197            display_orientation: u32,  // offset  84
198            display_fixed_output: u32, // offset  88
199            // post-union fields
200            color: u16,             // offset  92
201            duplex: u16,            // offset  94
202            y_resolution: u16,      // offset  96
203            tt_option: u16,         // offset  98
204            collate: u16,           // offset 100
205            form_name: [u16; 32],   // offset 102, 64 bytes
206            log_pixels: u16,        // offset 166
207            bits_per_pel: u32,      // offset 168 (4-byte aligned)
208            pels_width: u32,        // offset 172
209            pels_height: u32,       // offset 176
210            display_flags: u32,     // offset 180
211            display_frequency: u32, // offset 184
212            // extended fields
213            icm_method: u32,     // offset 188
214            icm_intent: u32,     // offset 192
215            media_type: u32,     // offset 196
216            dither_type: u32,    // offset 200
217            reserved1: u32,      // offset 204
218            reserved2: u32,      // offset 208
219            panning_width: u32,  // offset 212
220            panning_height: u32, // offset 216
221        } // total: 220 bytes
222
223        #[link(name = "user32")]
224        extern "system" {
225            fn EnumDisplayDevicesW(
226                lpDevice: *const u16,
227                iDevNum: u32,
228                lpDisplayDevice: *mut DisplayDevice,
229                dwFlags: u32,
230            ) -> i32;
231
232            fn EnumDisplaySettingsW(
233                lpszDeviceName: *const u16,
234                iModeNum: u32,
235                lpDevMode: *mut DevMode,
236            ) -> i32;
237        }
238
239        const DISPLAY_DEVICE_ACTIVE: u32 = 0x00000001;
240        const ENUM_CURRENT_SETTINGS: u32 = 0xFFFF_FFFF;
241
242        fn u16_to_string(buf: &[u16]) -> String {
243            let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
244            String::from_utf16_lossy(&buf[..len])
245        }
246
247        let mut displays = Vec::new();
248        let mut dev_num = 0u32;
249        loop {
250            let mut dd = DisplayDevice {
251                cb: std::mem::size_of::<DisplayDevice>() as u32,
252                device_name: [0u16; 32],
253                device_string: [0u16; 128],
254                state_flags: 0,
255                device_id: [0u16; 128],
256                device_key: [0u16; 128],
257            };
258            let ok = unsafe { EnumDisplayDevicesW(std::ptr::null(), dev_num, &mut dd, 0) };
259            if ok == 0 {
260                break;
261            }
262            dev_num += 1;
263
264            if dd.state_flags & DISPLAY_DEVICE_ACTIVE == 0 {
265                continue;
266            }
267
268            let adapter_name = u16_to_string(&dd.device_string);
269
270            let mut dm = unsafe { std::mem::zeroed::<DevMode>() };
271            dm.size = std::mem::size_of::<DevMode>() as u16;
272            let settings_ok = unsafe {
273                EnumDisplaySettingsW(dd.device_name.as_ptr(), ENUM_CURRENT_SETTINGS, &mut dm)
274            };
275
276            let entry = if settings_ok != 0 && dm.pels_width > 0 && dm.pels_height > 0 {
277                if dm.display_frequency > 0 {
278                    format!(
279                        "{} ({}x{} @ {}Hz)",
280                        adapter_name, dm.pels_width, dm.pels_height, dm.display_frequency
281                    )
282                } else {
283                    format!("{} ({}x{})", adapter_name, dm.pels_width, dm.pels_height)
284                }
285            } else {
286                adapter_name
287            };
288
289            if !entry.is_empty() {
290                displays.push(entry);
291            }
292        }
293
294        displays
295    }
296
297    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
298    {
299        let mut displays = Vec::new();
300
301        // Try reading directly from sysfs first for best performance
302        if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
303            for entry in entries.filter_map(|e| e.ok()) {
304                let path = entry.path();
305                let status_path = path.join("status");
306                let modes_path = path.join("modes");
307                let edid_path = path.join("edid");
308                if status_path.exists() && modes_path.exists() {
309                    if let Ok(status) = std::fs::read_to_string(&status_path) {
310                        if status.trim() == "connected" {
311                            if let Ok(modes) = std::fs::read_to_string(&modes_path) {
312                                if let Some(first_mode) = modes.lines().next() {
313                                    let res = first_mode.trim().to_string();
314                                    let port = entry.file_name().to_string_lossy().to_string();
315                                    let clean_port = if let Some(idx) = port.find('-') {
316                                        port[idx + 1..].to_string()
317                                    } else {
318                                        port
319                                    };
320
321                                    let edid_bytes = if edid_path.exists() {
322                                        std::fs::read(&edid_path).ok()
323                                    } else {
324                                        None
325                                    };
326
327                                    let name = edid_bytes
328                                        .as_ref()
329                                        .and_then(|bytes| parse_monitor_name_from_edid(bytes))
330                                        .unwrap_or(clean_port);
331
332                                    let refresh = edid_bytes
333                                        .as_ref()
334                                        .and_then(|bytes| parse_refresh_rate_from_edid(bytes));
335
336                                    let serial = edid_bytes
337                                        .as_ref()
338                                        .and_then(|bytes| parse_serial_number_from_edid(bytes));
339
340                                    let display_name = if let Some(ref s) = serial {
341                                        format!("{} #{}", name, s)
342                                    } else {
343                                        name
344                                    };
345
346                                    if let Some(r) = refresh {
347                                        displays.push(format!(
348                                            "{} ({} @ {}Hz)",
349                                            display_name,
350                                            res,
351                                            format_refresh_rate(r)
352                                        ));
353                                    } else {
354                                        displays.push(format!("{} ({})", display_name, res));
355                                    }
356                                }
357                            }
358                        }
359                    }
360                }
361            }
362        }
363
364        // Fallback to xrandr only if sysfs yielded no connected displays
365        if displays.is_empty() {
366            if let Ok(output) = std::process::Command::new("xrandr")
367                .arg("--current")
368                .output()
369            {
370                if let Ok(stdout) = String::from_utf8(output.stdout) {
371                    displays = parse_xrandr_displays(&stdout);
372                }
373            }
374        }
375
376        displays
377    }
378}
379
380/// Parse connected display information from `system_profiler SPDisplaysDataType` output.
381///
382/// Returns a list of strings in the form `"<Name> (<Resolution> @ <Rate>)"`.
383#[cfg(target_os = "macos")]
384pub fn parse_macos_displays(stdout: &str) -> Vec<String> {
385    let mut displays = Vec::new();
386    let mut current_name = None;
387    let mut current_res = None;
388    let mut in_displays = false;
389
390    for line in stdout.lines() {
391        let trimmed = line.trim();
392        let indent = line.len() - line.trim_start().len();
393
394        if trimmed.starts_with("Displays:") {
395            in_displays = true;
396            continue;
397        }
398
399        if in_displays {
400            if indent < 8 && !trimmed.is_empty() && !trimmed.starts_with("Displays:") {
401                in_displays = false;
402                continue;
403            }
404
405            if trimmed.ends_with(':') && !trimmed.starts_with("UI Looks like:") {
406                let name = trimmed.trim_end_matches(':').trim().to_string();
407                current_name = Some(name);
408            } else if trimmed.starts_with("Resolution:") {
409                let res = trimmed.strip_prefix("Resolution:").unwrap_or("").trim();
410                let cleaned = res.replace(" ", "");
411                current_res = Some(cleaned);
412            } else if trimmed.starts_with("UI Looks like:") {
413                if let Some(res) = current_res.take() {
414                    let name_str = current_name.take().unwrap_or_else(|| "Display".to_string());
415                    if let Some(idx) = trimmed.find('@') {
416                        let freq = trimmed[idx..].trim();
417                        let freq_clean = freq.replace(" ", "").replace(".00", "");
418                        displays.push(format!(
419                            "{} ({} @ {})",
420                            name_str,
421                            res,
422                            freq_clean.trim_start_matches('@')
423                        ));
424                    } else {
425                        displays.push(format!("{} ({})", name_str, res));
426                    }
427                }
428            }
429        }
430    }
431    if let Some(res) = current_res {
432        let name_str = current_name.unwrap_or_else(|| "Display".to_string());
433        displays.push(format!("{} ({})", name_str, res));
434    }
435    displays
436}
437
438/// Parse connected display information from `xrandr --current` output.
439///
440/// Returns a list of strings in the form `"<Name> (<Resolution> @ <Rate>Hz)"`.
441/// For each connected port, looks up the monitor name from EDID via
442/// [`get_monitor_name_for_port`], falling back to the port name itself.
443#[cfg(not(any(target_os = "macos", target_os = "windows")))]
444pub fn parse_xrandr_displays(stdout: &str) -> Vec<String> {
445    parse_xrandr_displays_with(stdout, get_monitor_name_for_port)
446}
447
448/// Pure core of [`parse_xrandr_displays`], parameterised over the port→monitor-name
449/// resolver so the parsing logic can be unit-tested without touching live `/sys/class/drm`
450/// EDID data.
451///
452/// `resolve` maps an xrandr connector name (e.g. `"DP-1"`) to a human-readable monitor
453/// model, or `None` when no EDID model is available — in which case the connector name is
454/// used verbatim. Production passes [`get_monitor_name_for_port`]; tests pass a stub
455/// (typically `|_| None`) so assertions see the fixture's connector names rather than
456/// whatever monitors happen to be plugged into the machine running the test suite.
457pub fn parse_xrandr_displays_with(
458    stdout: &str,
459    resolve: impl Fn(&str) -> Option<String>,
460) -> Vec<String> {
461    let mut displays = Vec::new();
462    let mut current_display = None;
463    let mut current_port = None;
464    for line in stdout.lines() {
465        let line = line.trim();
466        if line.contains(" connected ") {
467            let parts: Vec<&str> = line.split_whitespace().collect();
468            if let Some(&port) = parts.first() {
469                current_port = Some(port.to_string());
470            }
471            for part in parts {
472                if part.contains('x') && part.contains('+') {
473                    if let Some(res) = part.split('+').next() {
474                        current_display = Some(res.to_string());
475                    }
476                }
477            }
478        } else if line.contains('*') {
479            if let Some(res) = current_display.take() {
480                let port = current_port.take().unwrap_or_default();
481                let name = resolve(&port).unwrap_or_else(|| port.clone());
482                let parts: Vec<&str> = line.split_whitespace().collect();
483                let mut added = false;
484                for part in parts {
485                    if part.contains('*') {
486                        let freq = part.trim_end_matches(['*', '+']);
487                        displays.push(format!("{} ({} @ {}Hz)", name, res, freq));
488                        added = true;
489                        break;
490                    }
491                }
492                if !added {
493                    displays.push(format!("{} ({})", name, res));
494                }
495            }
496        }
497    }
498    displays
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    // ── EDID helpers ─────────────────────────────────────────────────────────
506
507    /// Build a 128-byte all-zero EDID with the 1080p60 DTD at offset 54.
508    fn edid_1080p60() -> Vec<u8> {
509        let mut edid = vec![0u8; 128];
510        // Pixel clock 14850 (x 10 kHz = 148.5 MHz)
511        edid[54] = 0x02;
512        edid[55] = 0x3A;
513        // H Active 1920, H Blanking 280
514        edid[56] = 0x80;
515        edid[57] = 0x18;
516        edid[58] = 0x71;
517        // V Active 1080, V Blanking 45
518        edid[59] = 0x38;
519        edid[60] = 0x2D;
520        edid[61] = 0x40;
521        edid
522    }
523
524    /// Inject a Monitor Name Descriptor (tag 0xFC) at EDID offset 72.
525    fn inject_monitor_name(edid: &mut Vec<u8>, name: &[u8]) {
526        edid[72] = 0x00;
527        edid[73] = 0x00;
528        edid[74] = 0x00;
529        edid[75] = 0xFC;
530        for (i, &b) in name.iter().enumerate().take(13) {
531            edid[76 + i] = b;
532        }
533    }
534
535    // ── parse_monitor_name_from_edid ──────────────────────────────────────────
536
537    #[test]
538    fn test_monitor_name_too_short_edid() {
539        assert_eq!(parse_monitor_name_from_edid(&[0u8; 64]), None);
540    }
541
542    #[test]
543    fn test_monitor_name_no_descriptor() {
544        // 128-byte EDID with no 0xFC descriptor block -> None
545        assert_eq!(parse_monitor_name_from_edid(&[0u8; 128]), None);
546    }
547
548    #[test]
549    fn test_monitor_name_at_offset_72() {
550        let mut edid = vec![0u8; 128];
551        inject_monitor_name(&mut edid, b"DELL S3422DW\n");
552        assert_eq!(
553            parse_monitor_name_from_edid(&edid),
554            Some("DELL S3422DW".to_string())
555        );
556    }
557
558    #[test]
559    fn test_monitor_name_at_offset_54() {
560        let mut edid = vec![0u8; 128];
561        edid[54] = 0x00;
562        edid[55] = 0x00;
563        edid[56] = 0x00;
564        edid[57] = 0xFC;
565        let name = b"LG 27UK850\n  ";
566        for (i, &b) in name.iter().enumerate().take(13) {
567            edid[58 + i] = b;
568        }
569        assert_eq!(
570            parse_monitor_name_from_edid(&edid),
571            Some("LG 27UK850".to_string())
572        );
573    }
574
575    // ── parse_refresh_rate_from_edid ──────────────────────────────────────────
576
577    #[test]
578    fn test_parse_refresh_rate_from_edid() {
579        let edid = edid_1080p60();
580        let refresh = parse_refresh_rate_from_edid(&edid);
581        assert!(refresh.is_some());
582        // 148,500,000 / ((1920 + 280) * (1080 + 45)) = 60 Hz
583        assert_eq!(refresh.unwrap(), 60.0);
584    }
585
586    #[test]
587    fn test_refresh_rate_too_short_edid() {
588        assert_eq!(parse_refresh_rate_from_edid(&[0u8; 71]), None);
589    }
590
591    #[test]
592    fn test_refresh_rate_zero_pixel_clock() {
593        // All-zero bytes -> pixel clock == 0 -> None
594        let edid = vec![0u8; 128];
595        assert_eq!(parse_refresh_rate_from_edid(&edid), None);
596    }
597
598    #[test]
599    fn test_refresh_rate_zero_totals() {
600        // Non-zero pixel clock but all dimension bytes zero -> h_total == 0 -> None
601        let mut edid = vec![0u8; 128];
602        edid[54] = 0x01; // pixel clock byte != 0
603                         // all active/blanking bytes remain 0
604        assert_eq!(parse_refresh_rate_from_edid(&edid), None);
605    }
606
607    // ── format_refresh_rate ───────────────────────────────────────────────────
608
609    #[test]
610    fn test_format_refresh_rate() {
611        assert_eq!(format_refresh_rate(60.0), "60");
612        assert_eq!(format_refresh_rate(59.94), "59.94");
613        assert_eq!(format_refresh_rate(143.971), "143.97");
614        assert_eq!(format_refresh_rate(120.0), "120");
615        assert_eq!(format_refresh_rate(240.0), "240");
616    }
617
618    // ── parse_serial_number_from_edid ─────────────────────────────────────────
619
620    #[test]
621    fn test_serial_too_short_edid() {
622        assert_eq!(parse_serial_number_from_edid(&[0u8; 64]), None);
623    }
624
625    #[test]
626    fn test_parse_serial_number_from_edid() {
627        let mut edid = vec![0u8; 128];
628        // 1. Fallback 32-bit numeric serial
629        edid[12] = 0x78;
630        edid[13] = 0x56;
631        edid[14] = 0x34;
632        edid[15] = 0x12; // 0x12345678 = 305419896
633        assert_eq!(
634            parse_serial_number_from_edid(&edid),
635            Some("305419896".to_string())
636        );
637
638        // 2. ASCII Monitor Serial Number descriptor block (tag 0xFF) at offset 72
639        edid[72] = 0x00;
640        edid[73] = 0x00;
641        edid[74] = 0x00;
642        edid[75] = 0xFF; // ASCII Serial Number descriptor tag
643        let serial_str = b"CN0123456789\n";
644        for i in 0..serial_str.len() {
645            edid[76 + i] = serial_str[i];
646        }
647        assert_eq!(
648            parse_serial_number_from_edid(&edid),
649            Some("CN0123456789".to_string())
650        );
651    }
652
653    #[test]
654    fn test_serial_numeric_zero_is_none() {
655        // All-zero EDID -> serial_num == 0 -> None
656        let edid = vec![0u8; 128];
657        assert_eq!(parse_serial_number_from_edid(&edid), None);
658    }
659
660    #[test]
661    fn test_serial_numeric_all_ff_is_none() {
662        let mut edid = vec![0u8; 128];
663        edid[12] = 0xFF;
664        edid[13] = 0xFF;
665        edid[14] = 0xFF;
666        edid[15] = 0xFF;
667        assert_eq!(parse_serial_number_from_edid(&edid), None);
668    }
669
670    #[test]
671    fn test_serial_ascii_takes_precedence_over_numeric() {
672        let mut edid = vec![0u8; 128];
673        // Non-zero numeric serial
674        edid[12] = 0x01;
675        edid[13] = 0x02;
676        edid[14] = 0x03;
677        edid[15] = 0x04;
678        // ASCII serial descriptor at offset 54
679        edid[54] = 0x00;
680        edid[55] = 0x00;
681        edid[56] = 0x00;
682        edid[57] = 0xFF;
683        let serial = b"ASCIIWIN0001\n";
684        for (i, &b) in serial.iter().enumerate().take(13) {
685            edid[58 + i] = b;
686        }
687        // ASCII descriptor should win over the numeric value
688        assert_eq!(
689            parse_serial_number_from_edid(&edid),
690            Some("ASCIIWIN0001".to_string())
691        );
692    }
693
694    // ── parse_xrandr_displays ─────────────────────────────────────────────────
695
696    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
697    #[test]
698    fn test_parse_xrandr_displays() {
699        let sample = "Screen 0: minimum 320 x 200, current 2560 x 1440\n\
700                      DP-1 connected primary 2560x1440+0+0\n\
701                         2560x1440     143.97*+\n\
702                         1920x1080     60.00\n";
703        let parsed = parse_xrandr_displays_with(sample, |_| None);
704        assert_eq!(parsed, vec!["DP-1 (2560x1440 @ 143.97Hz)".to_string()]);
705    }
706
707    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
708    #[test]
709    fn test_parse_xrandr_multiple_displays() {
710        let sample = "Screen 0: minimum 320 x 200, current 3840 x 1080\n\
711                      HDMI-1 connected 1920x1080+0+0\n\
712                         1920x1080     60.00*+\n\
713                      DP-1 connected 1920x1080+1920+0\n\
714                         1920x1080    144.00*+\n";
715        let parsed = parse_xrandr_displays_with(sample, |_| None);
716        assert_eq!(
717            parsed,
718            vec![
719                "HDMI-1 (1920x1080 @ 60.00Hz)".to_string(),
720                "DP-1 (1920x1080 @ 144.00Hz)".to_string(),
721            ]
722        );
723    }
724
725    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
726    #[test]
727    fn test_parse_xrandr_resolver_substitutes_monitor_name() {
728        // When the resolver returns a model name, it must replace the connector name;
729        // when it returns None, the connector name is kept verbatim.
730        let sample = "Screen 0: minimum 320 x 200, current 3840 x 1080\n\
731                      HDMI-1 connected 1920x1080+0+0\n\
732                         1920x1080     60.00*+\n\
733                      DP-1 connected 1920x1080+1920+0\n\
734                         1920x1080    144.00*+\n";
735        let parsed = parse_xrandr_displays_with(sample, |port| {
736            (port == "DP-1").then(|| "DELL S3422DW".to_string())
737        });
738        assert_eq!(
739            parsed,
740            vec![
741                "HDMI-1 (1920x1080 @ 60.00Hz)".to_string(),
742                "DELL S3422DW (1920x1080 @ 144.00Hz)".to_string(),
743            ]
744        );
745    }
746
747    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
748    #[test]
749    fn test_parse_xrandr_no_connected_displays() {
750        let sample = "Screen 0: minimum 320 x 200, current 0 x 0\n\
751                      HDMI-1 disconnected\n\
752                      DP-1 disconnected\n";
753        assert_eq!(
754            parse_xrandr_displays_with(sample, |_| None),
755            Vec::<String>::new()
756        );
757    }
758
759    // ── parse_macos_displays ──────────────────────────────────────────────────
760
761    #[cfg(target_os = "macos")]
762    #[test]
763    fn test_parse_macos_displays() {
764        let sample = "Graphics/Displays:\n\n    Apple M2:\n\n      Chipset Model: Apple M2\n      Displays:\n        Color LCD:\n          Resolution: 3024 x 1964\n          UI Looks like: 1512 x 982 @ 60.00Hz\n";
765        let parsed = parse_macos_displays(sample);
766        assert_eq!(parsed, vec!["Color LCD (3024x1964 @ 60Hz)".to_string()]);
767    }
768
769    #[cfg(target_os = "macos")]
770    #[test]
771    fn test_parse_macos_no_frequency() {
772        let sample = "Graphics/Displays:\n      Displays:\n        ASUS PA329C:\n          Resolution: 3840 x 2160\n";
773        let parsed = parse_macos_displays(sample);
774        assert_eq!(parsed, vec!["ASUS PA329C (3840x2160)".to_string()]);
775    }
776}