wallswitch 0.56.0

randomly selects wallpapers for multiple monitors
Documentation
use crate::{Config, Desktop, dependencies::is_installed, exec_cmd};
use std::{fs, path::PathBuf, process::Command};

/// Detects active outputs (monitors) using a robust fallback chain.
pub fn detect_monitors(config: &Config) -> Vec<String> {
    let mut monitors = Vec::new();

    // 1. Try Desktop-specific tools first
    match config.desktop {
        Desktop::Niri if is_installed("niri") => {
            if let Ok(out) = Command::new("niri").args(["msg", "outputs"]).output() {
                monitors = parse_niri(&String::from_utf8_lossy(&out.stdout));
            }
        }
        Desktop::Hyprland if is_installed("hyprctl") => {
            if let Ok(out) = Command::new("hyprctl").arg("monitors").output() {
                monitors = parse_hyprland(&String::from_utf8_lossy(&out.stdout));
            }
        }
        /*
            Get xfce monitors

            Example:
            ```
            // xfconf-query -c xfce4-desktop -p /backdrop -l | grep last-image
            // xfconf-query -c xfce4-desktop -p /backdrop -l | grep 'workspace0/last-image'

            let monitors = [
                "/backdrop/screen0/monitorDP-0/workspace0/last-image",
                "/backdrop/screen0/monitorDP-2/workspace0/last-image",
            ];
            ```
        */
        Desktop::Xfce if is_installed("xfconf-query") => {
            let mut cmd = Command::new("xfconf-query");
            cmd.args([
                "--channel",
                "xfce4-desktop",
                "--property",
                "/backdrop",
                "--list",
            ]);
            if let Ok(out) = exec_cmd(&mut cmd, config.verbose, "get_xfce") {
                monitors = parse_xfce(&String::from_utf8_lossy(&out.stdout));
            }
        }
        _ => {}
    }

    if !monitors.is_empty() {
        return monitors;
    }

    // 2. Generic Wayland fallback (wlr-randr)
    if is_installed("wlr-randr")
        && let Ok(out) = Command::new("wlr-randr").output()
    {
        monitors = parse_wlr_randr(&String::from_utf8_lossy(&out.stdout));
        if !monitors.is_empty() {
            return monitors;
        }
    }

    // 3. Hardware fallback (DRM Sysfs - Linux Kernel)
    monitors = detect_drm_monitors();
    if !monitors.is_empty() {
        return monitors;
    }

    // 4. Absolute Fallback
    if config.verbose {
        println!("All monitor detection methods failed. Using defaults.");
    }
    vec!["DP-1".to_string(), "DP-2".to_string()]
}

/// Pure parser for Niri output
pub fn parse_niri(stdout: &str) -> Vec<String> {
    stdout
        .lines()
        .filter(|line| line.starts_with("Output"))
        .filter_map(|line| {
            let start = line.rfind('(')?;
            let end = line.rfind(')')?;
            if start < end {
                Some(line[start + 1..end].to_string())
            } else {
                None
            }
        })
        .collect()
}

/// Pure parser for Hyprland output
pub fn parse_hyprland(stdout: &str) -> Vec<String> {
    stdout
        .lines()
        .filter(|line| line.starts_with("Monitor"))
        .filter_map(|line| line.split_whitespace().nth(1).map(String::from))
        .collect()
}

/// Pure parser for wlr-randr output
pub fn parse_wlr_randr(stdout: &str) -> Vec<String> {
    stdout
        .lines()
        .filter(|line| !line.starts_with(' ') && !line.is_empty())
        .filter_map(|line| line.split_whitespace().next().map(String::from))
        .collect()
}

/// Pure parser for XFCE output
pub fn parse_xfce(stdout: &str) -> Vec<String> {
    let words = ["screen0", "workspace0", "last-image"];
    stdout
        .trim()
        .split(['\n', ' '])
        .filter(|out| words.iter().all(|w| out.contains(w)))
        .map(String::from)
        .collect()
}

/// Hardware DRM parser
fn detect_drm_monitors() -> Vec<String> {
    let mut monitors = Vec::new();
    let drm_path = PathBuf::from("/sys/class/drm");

    if let Ok(entries) = fs::read_dir(drm_path) {
        for entry in entries.flatten() {
            let name = entry.file_name().to_string_lossy().to_string();
            if name.starts_with("card") && name.contains('-') {
                let status_path = entry.path().join("status");
                if let Ok(status) = fs::read_to_string(status_path)
                    && status.trim() == "connected"
                    && let Some(idx) = name.find('-')
                {
                    monitors.push(name[idx + 1..].to_string());
                }
            }
        }
    }
    monitors
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_monitor_parsers() {
        let expected = vec!["DP-1", "DP-2"];

        // Mocked Niri output
        let niri_mock = "\
Output eDP-1 (DP-1)
  Mode: 1920x1080
Output HDMI-A-1 (DP-2)
  Mode: 1920x1080";
        assert_eq!(parse_niri(niri_mock), expected);

        // Mocked Hyprland output
        let hypr_mock = "\
Monitor DP-1 (ID 0):
  1920x1080@60.00000
Monitor DP-2 (ID 1):
  1920x1080@60.00000";
        assert_eq!(parse_hyprland(hypr_mock), expected);

        // Mocked wlr-randr output
        let wlr_mock = "\
DP-1 \"Manufacturer X\"
  Position: 0,0
DP-2 \"Manufacturer Y\"
  Position: 1920,0";
        assert_eq!(parse_wlr_randr(wlr_mock), expected);

        // Mocked XFCE output
        let xfce_mock = "\
/backdrop/screen0/monitorDP-1/workspace0/last-image
/backdrop/screen0/monitorDP-2/workspace0/last-image
/backdrop/screen0/monitorDP-1/workspace0/color-style";
        let xfce_expected = vec![
            "/backdrop/screen0/monitorDP-1/workspace0/last-image",
            "/backdrop/screen0/monitorDP-2/workspace0/last-image",
        ];
        assert_eq!(parse_xfce(xfce_mock), xfce_expected);
    }
}