waybar-dynamic-ctl 0.1.0

CLI tool for controlling waybar-dynamic modules via Unix socket.
use std::io::{self, Read, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::process::ExitCode;

use clap::{Parser, Subcommand};
use waybar_dynamic_core::protocol::IpcMessage;
use waybar_dynamic_core::socket::socket_path;

#[derive(Parser)]
#[command(
    name = "waybar-dynamic-ctl",
    about = "Control a waybar-dynamic CFFI module instance",
    version
)]
struct Cli {
    /// Name of the module instance (matches the suffix after `cffi/` in waybar config).
    #[arg(short, long, default_value = "waybar-dynamic")]
    name: String,

    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Send a raw JSON IpcMessage to the module.
    Send {
        /// The JSON payload. If omitted, reads from stdin.
        json: Option<String>,
    },
    /// Build libwaybar_dynamic.so and copy it to a target directory.
    ///
    /// Runs `cargo build --release -p waybar-dynamic` and copies the
    /// resulting .so file. Requires the waybar-dynamic source to be
    /// available (either as a local checkout or via cargo).
    Install {
        /// Directory to copy the .so file to.
        /// Defaults to ~/.config/waybar/modules/
        dest: Option<PathBuf>,
    },
}

fn run() -> Result<(), String> {
    let cli = Cli::parse();

    match cli.command {
        Command::Send { json } => cmd_send(&cli.name, json),
        Command::Install { dest } => cmd_install(dest),
    }
}

fn cmd_send(name: &str, json: Option<String>) -> Result<(), String> {
    let raw = match json {
        Some(s) => s,
        None => {
            let mut buf = String::new();
            io::stdin()
                .read_to_string(&mut buf)
                .map_err(|e| format!("failed to read stdin: {e}"))?;
            buf
        }
    };

    let msg: IpcMessage =
        serde_json::from_str(&raw).map_err(|e| format!("invalid JSON: {e}"))?;
    let compact =
        serde_json::to_string(&msg).map_err(|e| format!("failed to serialize: {e}"))?;

    let path = socket_path(name);
    let mut stream = UnixStream::connect(&path)
        .map_err(|e| format!("could not connect to {}: {e}", path.display()))?;

    stream
        .write_all(compact.as_bytes())
        .map_err(|e| format!("failed to write: {e}"))?;
    stream
        .write_all(b"\n")
        .map_err(|e| format!("failed to write delimiter: {e}"))?;
    stream
        .flush()
        .map_err(|e| format!("failed to flush: {e}"))?;

    Ok(())
}

fn cmd_install(dest: Option<PathBuf>) -> Result<(), String> {
    let dest_dir = dest.unwrap_or_else(|| {
        dirs::home_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join(".config/waybar/modules")
    });

    std::fs::create_dir_all(&dest_dir)
        .map_err(|e| format!("failed to create {}: {e}", dest_dir.display()))?;

    // Run cargo build --release -p waybar-dynamic
    eprintln!("Building libwaybar_dynamic.so (release)...");
    let status = std::process::Command::new("cargo")
        .args(["build", "--release", "-p", "waybar-dynamic"])
        .status()
        .map_err(|e| format!("failed to run cargo build: {e}"))?;

    if !status.success() {
        return Err("cargo build failed".to_string());
    }

    // Find the .so in target/release/
    let so_name = so_filename();
    let source = find_so(&so_name)?;

    let dest_file = dest_dir.join(&so_name);
    std::fs::copy(&source, &dest_file)
        .map_err(|e| format!("failed to copy {}{}: {e}", source.display(), dest_file.display()))?;

    eprintln!("Installed {}{}", so_name, dest_file.display());
    Ok(())
}

fn so_filename() -> String {
    if cfg!(target_os = "macos") {
        "libwaybar_dynamic.dylib".to_string()
    } else {
        "libwaybar_dynamic.so".to_string()
    }
}

fn find_so(so_name: &str) -> Result<PathBuf, String> {
    // Try cargo metadata to find target dir, fall back to common paths.
    let output = std::process::Command::new("cargo")
        .args(["metadata", "--format-version=1", "--no-deps"])
        .output()
        .map_err(|e| format!("failed to run cargo metadata: {e}"))?;

    if output.status.success() {
        if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&output.stdout) {
            if let Some(target_dir) = json.get("target_directory").and_then(|v| v.as_str()) {
                let path = PathBuf::from(target_dir).join("release").join(so_name);
                if path.exists() {
                    return Ok(path);
                }
            }
        }
    }

    // Fallback: try ./target/release/
    let fallback = PathBuf::from("target/release").join(so_name);
    if fallback.exists() {
        return Ok(fallback);
    }

    Err(format!(
        "could not find {} in target/release/ — are you in the waybar-dynamic workspace?",
        so_name
    ))
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(msg) => {
            eprintln!("error: {msg}");
            ExitCode::FAILURE
        }
    }
}