framesmith-cli 0.1.0

CLI tool for controlling Samsung Frame TVs over the local network
use std::path::PathBuf;
use std::time::Duration;

use anyhow::{Context, Result, bail};

use crate::client::{self, Client};
use crate::ipc::{self, Request, Response, ServerMessage, VersionedRequest};
use crate::server;

fn versioned(request: Request) -> VersionedRequest {
    VersionedRequest {
        version: ipc::PROTOCOL_VERSION.to_string(),
        request,
    }
}

fn format_duration(secs: u64) -> String {
    if secs < 60 {
        return format!("{secs}s");
    }

    let days = secs / 86400;
    let hours = (secs % 86400) / 3600;
    let mins = (secs % 3600) / 60;
    let s = secs % 60;

    let mut parts = Vec::new();
    if days > 0 {
        parts.push(format!("{days}d"));
    }
    if hours > 0 {
        parts.push(format!("{hours}h"));
    }
    if mins > 0 {
        parts.push(format!("{mins}m"));
    }
    if s > 0 {
        parts.push(format!("{s}s"));
    }
    parts.join(" ")
}

pub async fn start(
    host: &str,
    auth_token_file: &std::path::Path,
    timeout: u64,
    json: bool,
) -> Result<()> {
    if client::is_server_alive(host) {
        let pid = client::read_server_pid(host).unwrap_or(0);
        if json {
            println!(
                "{}",
                serde_json::json!({"status": "already_running", "pid": pid})
            );
        } else {
            println!("Server already running for {host} (PID {pid}).");
        }
        return Ok(());
    }

    // Clean up stale files
    let _ = std::fs::remove_file(server::pid_path(host));
    let _ = std::fs::remove_file(server::socket_path(host));

    // Spawn server
    let client = Client::new(host, auth_token_file, timeout);
    // Use connect_or_start logic by sending a Status request
    let resp = client.send_request(Request::Status).await?;

    match resp {
        Response::Ok { data } => {
            let pid = data.get("pid").and_then(|v| v.as_u64()).unwrap_or(0);
            if json {
                println!("{}", serde_json::json!({"status": "started", "pid": pid}));
            } else {
                println!("Server started for {host} (PID {pid}).");
            }
        }
        Response::Error { message } => bail!("server start failed: {message}"),
        Response::TvDisconnected { message } => bail!("TV not reachable: {message}"),
    }

    Ok(())
}

pub async fn stop(host: &str, json: bool) -> Result<()> {
    if !client::is_server_alive(host) {
        if json {
            println!("{}", serde_json::json!({"status": "not_running"}));
        } else {
            println!("No server running for {host}.");
        }
        return Ok(());
    }

    let sock = server::socket_path(host);
    let stream = tokio::net::UnixStream::connect(&sock)
        .await
        .context("failed to connect to server")?;
    let (mut reader, mut writer) = stream.into_split();

    ipc::write_message(&mut writer, &versioned(Request::Shutdown)).await?;

    // Wait for ShuttingDown message
    let _ = tokio::time::timeout(
        Duration::from_secs(5),
        ipc::read_message::<_, ServerMessage>(&mut reader),
    )
    .await;

    if json {
        println!("{}", serde_json::json!({"status": "stopped"}));
    } else {
        println!("Server for {host} stopped.");
    }
    Ok(())
}

pub async fn restart(
    host: &str,
    auth_token_file: &std::path::Path,
    timeout: u64,
    json: bool,
) -> Result<()> {
    // Stop if running
    if client::is_server_alive(host) {
        let sock = server::socket_path(host);
        if let Ok(stream) = tokio::net::UnixStream::connect(&sock).await {
            let (mut reader, mut writer) = stream.into_split();
            let _ = ipc::write_message(&mut writer, &versioned(Request::Shutdown)).await;
            let _ = tokio::time::timeout(
                Duration::from_secs(5),
                ipc::read_message::<_, ServerMessage>(&mut reader),
            )
            .await;
        }

        // Wait for process to exit
        for _ in 0..20 {
            if !client::is_server_alive(host) {
                break;
            }
            tokio::time::sleep(Duration::from_millis(100)).await;
        }
    }

    // Start fresh
    start(host, auth_token_file, timeout, json).await
}

pub async fn status(host: &str, json: bool) -> Result<()> {
    if !client::is_server_alive(host) {
        if json {
            println!(
                "{}",
                serde_json::json!({"status": "not_running", "host": host})
            );
        } else {
            println!("No server running for {host}.");
        }
        return Ok(());
    }

    let sock = server::socket_path(host);
    let stream = tokio::net::UnixStream::connect(&sock)
        .await
        .context("failed to connect to server")?;
    let (mut reader, mut writer) = stream.into_split();

    ipc::write_message(&mut writer, &versioned(Request::Status)).await?;

    match tokio::time::timeout(
        Duration::from_secs(5),
        ipc::read_message::<_, ServerMessage>(&mut reader),
    )
    .await
    {
        Ok(Ok(ServerMessage::Response(Response::Ok { data }))) => {
            if json {
                println!("{}", serde_json::to_string_pretty(&data)?);
            } else {
                let pid = data.get("pid").and_then(|v| v.as_u64()).unwrap_or(0);
                let host_val = data
                    .get("host")
                    .and_then(|v| v.as_str())
                    .unwrap_or("unknown");
                let log = data
                    .get("log_path")
                    .and_then(|v| v.as_str())
                    .unwrap_or("unknown");
                let uptime = data
                    .get("server_uptime_secs")
                    .and_then(|v| v.as_u64())
                    .unwrap_or(0);
                let tv_connected = data
                    .get("tv_connected")
                    .and_then(|v| v.as_bool())
                    .unwrap_or(false);
                let conn_uptime = data.get("connection_uptime_secs").and_then(|v| v.as_u64());
                let last_error = data.get("last_error").and_then(|v| v.as_str());

                println!("PID:               {pid}");
                println!("Host:              {host_val}");
                println!("Log:               {log}");
                println!("Server uptime:     {}", format_duration(uptime));
                println!(
                    "TV connected:      {}",
                    if tv_connected { "yes" } else { "no" }
                );
                if let Some(cu) = conn_uptime {
                    println!("Connection uptime: {}", format_duration(cu));
                }
                if let Some(err) = last_error {
                    println!("Last error:        {err}");
                }
            }
        }
        Ok(Ok(ServerMessage::Response(Response::Error { message }))) => {
            bail!("server error: {message}");
        }
        _ => bail!("unexpected response from server"),
    }

    Ok(())
}

pub async fn tail_logs(host: &str) -> Result<()> {
    // Try to get the log path from a running server first, fall back to computed path
    let log_path = get_log_path_from_server(host)
        .await
        .unwrap_or_else(|| server::log_path(host));

    if !log_path.exists() {
        bail!(
            "Log file does not exist: {}\nIs a server running for {host}? Try: framesmith {host} server start",
            log_path.display()
        );
    }

    eprintln!("Tailing {}", log_path.display());

    // Replace the current process with tail -F.
    // CommandExt::exec() calls execvp — no shell involved, no injection risk.
    use std::os::unix::process::CommandExt;
    let err = std::process::Command::new("tail")
        .args(["-F", &log_path.to_string_lossy()])
        .exec();
    bail!("failed to exec tail: {err}");
}

async fn get_log_path_from_server(host: &str) -> Option<PathBuf> {
    if !client::is_server_alive(host) {
        return None;
    }

    let sock = server::socket_path(host);
    let stream = tokio::net::UnixStream::connect(&sock).await.ok()?;
    let (mut reader, mut writer) = stream.into_split();

    ipc::write_message(&mut writer, &versioned(Request::Status))
        .await
        .ok()?;

    let result = tokio::time::timeout(
        Duration::from_secs(5),
        ipc::read_message::<_, ServerMessage>(&mut reader),
    )
    .await;

    if let Ok(Ok(ServerMessage::Response(Response::Ok { data }))) = result {
        data.get("log_path")
            .and_then(|v| v.as_str())
            .map(PathBuf::from)
    } else {
        None
    }
}