nighterrors 0.1.0

Ultra-light Rust Wayland blue-light filter daemon for Hyprland/Niri and wlroots compositors
use std::ffi::OsString;
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};

use crate::cli::{ServiceCommand, ServiceInstallOptions};

const UNIT_NAME: &str = "nighterrors.service";

pub fn run(command: ServiceCommand) -> Result<(), String> {
    match command {
        ServiceCommand::Install(options) => install(options),
        ServiceCommand::Uninstall => uninstall(),
        ServiceCommand::Status => run_unit_command("status"),
        ServiceCommand::Start => run_unit_command("start"),
        ServiceCommand::Stop => run_unit_command("stop"),
        ServiceCommand::Restart => run_unit_command("restart"),
    }
}

fn install(options: ServiceInstallOptions) -> Result<(), String> {
    let unit_dir = user_unit_dir()?;
    fs::create_dir_all(&unit_dir).map_err(|err| {
        format!(
            "failed to create user systemd unit directory {}: {err}",
            unit_dir.display()
        )
    })?;

    let unit_path = unit_dir.join(UNIT_NAME);
    let unit_contents = render_unit_file(&options)?;
    fs::write(&unit_path, unit_contents)
        .map_err(|err| format!("failed to write unit file {}: {err}", unit_path.display()))?;

    run_systemctl_user(["daemon-reload"])?;
    run_systemctl_user(["enable", "--now", UNIT_NAME])?;

    println!(
        "Installed and enabled {} at {}",
        UNIT_NAME,
        unit_path.display()
    );
    println!("Check status with: systemctl --user status {}", UNIT_NAME);
    Ok(())
}

fn uninstall() -> Result<(), String> {
    let unit_path = user_unit_dir()?.join(UNIT_NAME);

    let disable_result = run_systemctl_user(["disable", "--now", UNIT_NAME]);
    if let Err(err) = disable_result
        && !is_missing_unit_error(&err)
    {
        return Err(err);
    }

    if unit_path.exists() {
        fs::remove_file(&unit_path)
            .map_err(|err| format!("failed to remove unit file {}: {err}", unit_path.display()))?;
    }

    run_systemctl_user(["daemon-reload"])?;
    println!("Uninstalled {}", UNIT_NAME);
    Ok(())
}

fn run_unit_command(action: &str) -> Result<(), String> {
    let output = run_systemctl_user([action, UNIT_NAME])?;
    match action {
        "status" => {
            let stdout = String::from_utf8_lossy(&output.stdout);
            let stderr = String::from_utf8_lossy(&output.stderr);
            if !stdout.trim().is_empty() {
                println!("{}", stdout.trim_end());
            }
            if !stderr.trim().is_empty() {
                eprintln!("{}", stderr.trim_end());
            }
        }
        "start" => println!("Started {}", UNIT_NAME),
        "stop" => println!("Stopped {}", UNIT_NAME),
        "restart" => println!("Restarted {}", UNIT_NAME),
        _ => {}
    }
    Ok(())
}

fn run_systemctl_user<I, S>(args: I) -> Result<Output, String>
where
    I: IntoIterator<Item = S>,
    S: Into<OsString>,
{
    run_command(
        "systemctl",
        std::iter::once("--user".into()).chain(args.into_iter().map(Into::into)),
    )
}

fn run_command<I, S>(program: &str, args: I) -> Result<Output, String>
where
    I: IntoIterator<Item = S>,
    S: Into<OsString>,
{
    let mut command = Command::new(program);
    let argv: Vec<OsString> = args.into_iter().map(Into::into).collect();
    command.args(&argv);

    let output = command
        .output()
        .map_err(|err| format!("failed to execute `{program}`: {err}"))?;
    if output.status.success() {
        return Ok(output);
    }

    Err(format_command_error(program, &output))
}

fn format_command_error(program: &str, output: &Output) -> String {
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    let mut message = format!(
        "{program} exited with status {}",
        output.status.code().unwrap_or(-1)
    );

    if !stdout.trim().is_empty() {
        message.push_str(&format!("\nstdout:\n{}", stdout.trim()));
    }
    if !stderr.trim().is_empty() {
        message.push_str(&format!("\nstderr:\n{}", stderr.trim()));
    }
    if message.contains("Failed to connect to bus") {
        message.push_str(
            "\nhint: ensure a user systemd session is available and run this from a graphical login.",
        );
    }

    message
}

fn is_missing_unit_error(err: &str) -> bool {
    let lowered = err.to_ascii_lowercase();
    lowered.contains("not loaded")
        || lowered.contains("does not exist")
        || lowered.contains("no such file")
        || lowered.contains("unit nighterrors.service not found")
}

fn user_unit_dir() -> Result<PathBuf, String> {
    let config_home = std::env::var("XDG_CONFIG_HOME").ok();
    let home = std::env::var("HOME").ok();
    user_unit_dir_from(config_home.as_deref(), home.as_deref())
}

fn user_unit_dir_from(config_home: Option<&str>, home: Option<&str>) -> Result<PathBuf, String> {
    if let Some(config_home) = config_home
        && !config_home.is_empty()
    {
        return Ok(PathBuf::from(config_home).join("systemd").join("user"));
    }

    let home = home
        .ok_or_else(|| "cannot resolve HOME or XDG_CONFIG_HOME for user unit path".to_string())?;
    Ok(PathBuf::from(home)
        .join(".config")
        .join("systemd")
        .join("user"))
}

fn render_unit_file(options: &ServiceInstallOptions) -> Result<String, String> {
    let exec_start = render_exec_start(options)?;
    Ok(format!(
        "[Unit]\nDescription=Nighterrors Wayland color temperature daemon\nAfter=graphical-session.target\nPartOf=graphical-session.target\n\n[Service]\nType=simple\nExecStart={exec_start}\nRestart=on-failure\nRestartSec=1\nPassEnvironment=WAYLAND_DISPLAY XDG_RUNTIME_DIR\nEnvironment=XDG_RUNTIME_DIR=%t\n\n[Install]\nWantedBy=default.target\n"
    ))
}

fn render_exec_start(options: &ServiceInstallOptions) -> Result<String, String> {
    let binary = current_binary_path()?;
    let mut args = vec![escape_exec_arg(binary.to_string_lossy().as_ref())];
    args.push("run".to_string());
    args.push("--temperature".to_string());
    args.push(options.run_options.temperature_k.to_string());
    args.push("--gamma".to_string());
    args.push(format_float(options.run_options.gamma_pct));
    args.push(format!(
        "--identity={}",
        if options.run_options.identity {
            "true"
        } else {
            "false"
        }
    ));

    if let Some(socket) = &options.socket {
        args.push("--socket".to_string());
        args.push(escape_exec_arg(socket.to_string_lossy().as_ref()));
    }

    for excluded in &options.run_options.excludes {
        args.push("--exclude".to_string());
        args.push(escape_exec_arg(excluded));
    }

    if options.run_options.verbose {
        args.push("--verbose".to_string());
    }

    Ok(args.join(" "))
}

fn current_binary_path() -> Result<PathBuf, String> {
    let path = std::env::current_exe()
        .map_err(|err| format!("failed to resolve current binary path: {err}"))?;
    match fs::canonicalize(&path) {
        Ok(canonical) => Ok(canonical),
        Err(_) => Ok(path),
    }
}

fn escape_exec_arg(value: &str) -> String {
    if value.is_empty() {
        return "\"\"".to_string();
    }

    if value
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || "-_./:@".contains(ch))
    {
        return value.to_string();
    }

    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
    format!("\"{escaped}\"")
}

fn format_float(value: f64) -> String {
    let candidate = format!("{value:.6}");
    let trimmed = candidate.trim_end_matches('0').trim_end_matches('.');
    if trimmed.is_empty() {
        "0".to_string()
    } else {
        trimmed.to_string()
    }
}

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

    fn test_install_options() -> ServiceInstallOptions {
        ServiceInstallOptions {
            socket: Some(PathBuf::from("/tmp/nighterrors.sock")),
            run_options: RunOptions {
                temperature_k: 5500,
                gamma_pct: 95.5,
                identity: false,
                excludes: vec!["eDP-1".to_string(), "HDMI A".to_string()],
                verbose: true,
            },
        }
    }

    #[test]
    fn escape_exec_arg_quotes_unsafe_values() {
        assert_eq!(escape_exec_arg("safe-thing_1"), "safe-thing_1");
        assert_eq!(escape_exec_arg(""), "\"\"");
        assert_eq!(escape_exec_arg("HDMI A"), "\"HDMI A\"");
    }

    #[test]
    fn render_exec_start_includes_run_options() {
        let options = test_install_options();
        let line = render_exec_start(&options).expect("exec start should render");
        assert!(line.contains(" run "));
        assert!(line.contains("--temperature 5500"));
        assert!(line.contains("--gamma 95.5"));
        assert!(line.contains("--identity=false"));
        assert!(line.contains("--socket /tmp/nighterrors.sock"));
        assert!(line.contains("--exclude eDP-1"));
        assert!(line.contains("--exclude \"HDMI A\""));
        assert!(line.contains("--verbose"));
    }

    #[test]
    fn render_unit_file_has_expected_sections() {
        let options = test_install_options();
        let unit = render_unit_file(&options).expect("unit should render");
        assert!(unit.contains("[Unit]"));
        assert!(unit.contains("[Service]"));
        assert!(unit.contains("[Install]"));
        assert!(unit.contains("WantedBy=default.target"));
        assert!(unit.contains("PassEnvironment=WAYLAND_DISPLAY XDG_RUNTIME_DIR"));
    }

    #[test]
    fn user_unit_dir_prefers_xdg_config_home() {
        let temp_dir = std::env::temp_dir().join("nighterrors-xdg-config-home");
        let dir = user_unit_dir_from(temp_dir.to_str(), Some("/home/example"))
            .expect("unit dir should resolve");
        assert_eq!(dir, temp_dir.join("systemd").join("user"));
    }

    #[test]
    fn user_unit_dir_falls_back_to_home() {
        let dir = user_unit_dir_from(None, Some("/home/example")).expect("unit dir should resolve");
        assert_eq!(dir, PathBuf::from("/home/example/.config/systemd/user"));
    }

    #[test]
    fn missing_unit_error_detection() {
        assert!(is_missing_unit_error(
            "Unit nighterrors.service not loaded."
        ));
        assert!(is_missing_unit_error(
            "Unit file nighterrors.service does not exist."
        ));
        assert!(!is_missing_unit_error("permission denied"));
    }
}