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