wallswitch 0.63.1

randomly selects wallpapers for multiple monitors
Documentation
use crate::{CommandExt, Config, Desktop, WallSwitchError, WallSwitchResult, is_installed};
use std::{
    fs,
    io::{Write, stdout},
    path::PathBuf,
    process::Command,
    thread::sleep,
};

/// Detects active outputs (monitors) using a robust fallback chain with a retry loop.
///
/// Under startup environments, the window manager IPC socket may not be immediately
/// ready. This function polls the detection mechanism for up to 3 seconds before failing.
pub fn detect_monitors(config: &Config) -> WallSwitchResult<Vec<String>> {
    let max_wait = std::time::Duration::from_secs_f32(3.0);
    let step = std::time::Duration::from_secs_f32(0.05); // 50ms is a safe and CPU-friendly polling interval
    let start_time = std::time::Instant::now();

    while start_time.elapsed() < max_wait {
        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));
                }
            }
            Desktop::Xfce if is_installed("xfconf-query") => {
                let active_xrandr_monitors = get_active_xrandr_monitors(config);
                let _ = prune_stale_xfce_configs(config, &active_xrandr_monitors);

                let mut cmd = Command::new("xfconf-query");
                cmd.args([
                    "--channel",
                    "xfce4-desktop",
                    "--property",
                    "/backdrop",
                    "--list",
                ]);
                if let Ok(out) = cmd.run_with_config(config, "xfconf-query") {
                    monitors = parse_xfce(
                        &String::from_utf8_lossy(&out.stdout),
                        &active_xrandr_monitors,
                    );
                }
            }
            _ => {}
        }

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

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

        // If at least one active monitor was successfully detected, proceed immediately
        if !monitors.is_empty() {
            if config.verbose && start_time.elapsed() > step {
                println!(
                    "System IPC initialized. Monitor detection succeeded after {:0.3}s.",
                    start_time.elapsed().as_secs_f32()
                );
            }
            return Ok(monitors);
        }

        if config.verbose {
            let elapsed_secs = start_time.elapsed().as_secs_f32();
            print!(
                "\rWait to detect monitors. Time: {elapsed_secs:0.2}/{:0.2}s",
                max_wait.as_secs_f32()
            );
            let _ = stdout().flush();
        }

        // Relinquish CPU slice before retrying
        sleep(step);
    }

    // 4. Fatal Error: Timeout reached and no monitors could be found
    Err(WallSwitchError::NoMonitors(
        "any system tool (X11/Wayland/DRM) within the initialization timeout".to_string(),
    ))
}

/// Get active X11 monitors via xrandr to filter out stale configurations.
///
/// Runs `xrandr --listactivemonitors` and safely parses the output.
pub fn get_active_xrandr_monitors(config: &Config) -> Vec<String> {
    let mut monitors = Vec::new();

    if is_installed("xrandr") {
        let mut cmd = Command::new("xrandr");
        cmd.args(["--listactivemonitors"]);
        if let Ok(out) = cmd.run_with_config(config, "xrandr") {
            let stdout = String::from_utf8_lossy(&out.stdout);
            monitors = parse_xrandr(&stdout);
        }
    }

    monitors
}

/// Pure parser for xrandr output.
pub fn parse_xrandr(stdout: &str) -> Vec<String> {
    stdout
        .lines()
        .filter_map(|line| {
            let tokens: Vec<&str> = line.split_whitespace().collect();
            if tokens.len() >= 3 {
                let first_token = tokens[0];
                if let Some(prefix) = first_token.strip_suffix(':')
                    && prefix.parse::<usize>().is_ok()
                {
                    return tokens.last().map(|&s| s.to_string());
                }
            }
            None
        })
        .collect()
}

/// XFCE Logic: Matches active hardware (xrandr) with XFCE properties.
/// If a monitor is active but has no XFCE property yet, we synthesize one.
pub fn parse_xfce(stdout: &str, active_monitors: &[String]) -> Vec<String> {
    let words = ["screen0", "workspace0", "last-image"];
    let existing_props: Vec<String> = stdout
        .trim()
        .split(['\n', ' '])
        .filter(|out| words.iter().all(|w| out.contains(w)))
        .map(String::from)
        .collect();

    if active_monitors.is_empty() {
        return existing_props;
    }

    let mut final_properties = Vec::new();

    for m in active_monitors {
        let prefix_match = format!("/monitor{}/", m);
        let exact_match = format!("/{}/", m);

        // Check if XFCE already has a property for this monitor
        if let Some(prop) = existing_props.iter().find(|p| p.contains(&prefix_match)) {
            final_properties.push(prop.clone());
        } else if let Some(prop) = existing_props.iter().find(|p| p.contains(&exact_match)) {
            final_properties.push(prop.clone());
        } else {
            // CRITICAL FIX: If monitor exists in xrandr but NOT in xfconf,
            // we create the expected path based on XFCE 4.18+ standards.
            // This allows the program to "force-set" the wallpaper on new monitors.
            let synthesized = format!("/backdrop/screen0/monitor{}/workspace0/last-image", m);
            final_properties.push(synthesized);
        }
    }

    // Deduplicate in case multiple matches occurred
    final_properties.sort();
    final_properties.dedup();
    final_properties
}

/// Prunes stale XFCE configurations that no longer correspond to active hardware.
pub fn prune_stale_xfce_configs(
    config: &Config,
    active_monitors: &[String],
) -> WallSwitchResult<()> {
    if active_monitors.is_empty() {
        return Ok(());
    }

    let mut cmd = Command::new("xfconf-query");
    cmd.args([
        "--channel",
        "xfce4-desktop",
        "--property",
        "/backdrop",
        "--list",
    ]);

    if let Ok(output) = cmd.run_with_config(config, "xfconf-query") {
        let stdout = String::from_utf8_lossy(&output.stdout);
        let stale_properties: Vec<String> = stdout
            .lines()
            .filter(|prop| prop.contains("workspace0/last-image"))
            .filter(|prop| {
                !active_monitors.iter().any(|m| {
                    prop.contains(&format!("/{}/", m)) || prop.contains(&format!("/monitor{}/", m))
                })
            })
            .map(String::from)
            .collect();

        for property in stale_properties {
            if let Some(monitor_root) = property.split("/workspace0").next() {
                if config.verbose {
                    println!("Pruning: {}", monitor_root);
                }
                if config.dry_run {
                    println!(
                        "[DRY-RUN] Would reset stale XFCE property: {}",
                        monitor_root
                    );
                } else {
                    let mut reset_cmd = Command::new("xfconf-query");
                    reset_cmd.args([
                        "--channel",
                        "xfce4-desktop",
                        "--property",
                        monitor_root,
                        "--reset",
                        "--recursive",
                    ]);
                    let _ = reset_cmd.run_with_config(config, &format!("Reset {}", monitor_root));
                }
            }
        }
    }
    Ok(())
}

/// 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()
}

/// Hardware DRM parser for Linux kernel.
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
}

//----------------------------------------------------------------------------//
//                                   Tests                                    //
//----------------------------------------------------------------------------//

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

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

        // Mocked xrandr output with malicious/unexpected edge cases
        let xrandr_mock = "\
Monitors: 2
 0: +DP-1 3840/621x2160/341+0+0  DP-1
 Error: bla bla
 A: +DP-2 3840/621x2160/341+0+0  DP-X
 1: +DP-2 3840/621x2160/341+3840+0  DP-2";
        assert_eq!(parse_xrandr(xrandr_mock), expected);

        // 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 with stale/duplicate entries
        let xfce_mock = "\
/backdrop/screen0/monitorASUSPB287Q/workspace0/last-image
/backdrop/screen0/monitorDP-1/workspace0/last-image
/backdrop/screen0/DP-2/workspace0/last-image
/backdrop/screen0/monitorDP-2/workspace0/last-image
/backdrop/screen0/monitorDP-1/workspace0/color-style";

        let active = vec!["DP-1".to_string(), "DP-2".to_string()];

        // It MUST prioritize prefix_match (/monitorDP-2/) over exact_match (/DP-2/)
        let xfce_expected = vec![
            "/backdrop/screen0/monitorDP-1/workspace0/last-image",
            "/backdrop/screen0/monitorDP-2/workspace0/last-image",
        ];
        assert_eq!(parse_xfce(xfce_mock, &active), xfce_expected);

        // Test fallback if xrandr doesn't match anything
        let empty_active = vec![];
        let xfce_expected_fallback = vec![
            "/backdrop/screen0/monitorASUSPB287Q/workspace0/last-image",
            "/backdrop/screen0/monitorDP-1/workspace0/last-image",
            "/backdrop/screen0/DP-2/workspace0/last-image",
            "/backdrop/screen0/monitorDP-2/workspace0/last-image",
        ];
        assert_eq!(parse_xfce(xfce_mock, &empty_active), xfce_expected_fallback);
    }
}