snipexpand 0.2.1

Fast, config-based text expansion for Linux and Wayland
use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::PathBuf;
use std::process::Command;

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AppInfo {
    pub title: Option<String>,
    pub class: Option<String>,
    pub exec: Option<String>,
}

#[derive(Deserialize)]
struct HyprlandWindow {
    #[serde(default)]
    title: String,
    #[serde(default)]
    class: String,
    pid: Option<u32>,
}

pub fn detect() -> Result<AppInfo> {
    if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() {
        return detect_hyprland();
    }

    detect_wlroots().context(
        "active-application detection is unavailable; Hyprland or wlrctl is required on Wayland",
    )
}

fn detect_hyprland() -> Result<AppInfo> {
    let output = Command::new("hyprctl")
        .args(["activewindow", "-j"])
        .output()
        .context("run 'hyprctl activewindow -j'")?;
    if !output.status.success() {
        anyhow::bail!(
            "hyprctl activewindow failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    let window: HyprlandWindow =
        serde_json::from_slice(&output.stdout).context("parse Hyprland active-window response")?;
    Ok(AppInfo {
        title: nonempty(window.title),
        class: nonempty(window.class),
        exec: window.pid.and_then(executable_for_pid),
    })
}

fn detect_wlroots() -> Result<AppInfo> {
    let output = Command::new("wlrctl")
        .args(["toplevel", "list", "state:active"])
        .output()
        .context("run 'wlrctl toplevel list state:active'")?;
    if !output.status.success() {
        anyhow::bail!(
            "wlrctl active-window query failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    let line = String::from_utf8(output.stdout).context("decode wlrctl output")?;
    let (class, title) = line
        .trim()
        .split_once(": ")
        .context("wlrctl returned no active toplevel")?;
    Ok(AppInfo {
        title: nonempty(title.to_string()),
        class: nonempty(class.to_string()),
        exec: None,
    })
}

fn executable_for_pid(pid: u32) -> Option<String> {
    std::fs::read_link(PathBuf::from("/proc").join(pid.to_string()).join("exe"))
        .ok()
        .map(|path| path.to_string_lossy().into_owned())
}

fn nonempty(value: String) -> Option<String> {
    (!value.is_empty()).then_some(value)
}

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

    #[test]
    fn empty_strings_become_missing_properties() {
        assert_eq!(nonempty(String::new()), None);
        assert_eq!(nonempty("foot".into()), Some("foot".into()));
    }
}