homeassistant-cli 0.2.3

Agent-friendly Home Assistant CLI with JSON output, structured exit codes, and schema introspection
Documentation
use std::io::IsTerminal;

use owo_colors::OwoColorize;

use crate::api::{self, HaClient, HaError};
use crate::output::{self, OutputConfig};

pub async fn fire(
    out: &OutputConfig,
    client: &HaClient,
    event_type: &str,
    data: Option<&str>,
    yes: bool,
) -> Result<(), HaError> {
    // Firing events modifies HA state. Without a TTY and without --yes or JSON mode: refuse.
    let is_tty = std::io::stdin().is_terminal();
    if !yes && !out.is_json() {
        if is_tty {
            eprint!("Fire event '{event_type}'? [y/N] ");
            use std::io::Write;
            let _ = std::io::stderr().flush();
            let mut input = String::new();
            std::io::stdin()
                .read_line(&mut input)
                .map_err(|e| HaError::Other(format!("failed to read stdin: {e}")))?;
            let answer = input.trim().to_ascii_lowercase();
            if answer != "y" && answer != "yes" {
                return Err(HaError::InvalidInput("aborted by user".into()));
            }
        } else {
            // Non-interactive, no --yes: refuse per spec Principle 4.
            return Err(HaError::ConfirmationRequired(format!(
                "Firing event '{event_type}' requires confirmation"
            )));
        }
    }

    let body = if let Some(d) = data {
        Some(
            serde_json::from_str::<serde_json::Value>(d)
                .map_err(|e| HaError::InvalidInput(format!("Invalid JSON data: {e}")))?,
        )
    } else {
        None
    };

    let result = api::events::fire_event(client, event_type, body.as_ref()).await?;

    if out.is_json() {
        out.print_data(
            &serde_json::to_string_pretty(&serde_json::json!({"ok": true, "data": result}))
                .expect("serialize"),
        );
    } else {
        out.print_data(&format!("Fired event: {event_type}"));
    }
    Ok(())
}

pub async fn watch(
    out: &OutputConfig,
    client: &HaClient,
    event_type: Option<&str>,
) -> Result<(), HaError> {
    out.print_message(&format!(
        "Watching events{} (Ctrl+C to stop)...",
        event_type.map(|t| format!(": {t}")).unwrap_or_default()
    ));

    api::events::watch_stream(client, event_type, |event| {
        if out.is_json() {
            if let Ok(s) =
                serde_json::to_string_pretty(&serde_json::json!({"ok": true, "data": event}))
            {
                println!("{s}");
            }
        } else {
            let time = event
                .time_fired
                .as_deref()
                .map(output::relative_time)
                .unwrap_or_else(|| "-".to_owned());
            let data_str = if event.data.is_null() || event.data == serde_json::json!({}) {
                String::new()
            } else {
                format!("  {}", event.data.to_string().dimmed())
            };
            println!("{}  {}{}", time.dimmed(), event.event_type.bold(), data_str);
        }
        true
    })
    .await
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::api::HaClient;
    use crate::output::{OutputConfig, OutputFormat};
    use wiremock::matchers::{method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn json_out() -> OutputConfig {
        OutputConfig::new(Some(OutputFormat::Json), false)
    }

    #[tokio::test]
    async fn fire_succeeds_on_200() {
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/api/events/my_event"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_json(serde_json::json!({"message": "Event my_event fired."})),
            )
            .mount(&server)
            .await;

        let client = HaClient::new(server.uri(), "tok");
        // yes=true to skip confirmation in non-TTY test environment
        let result = fire(&json_out(), &client, "my_event", None, true).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn fire_with_invalid_json_returns_error() {
        let server = MockServer::start().await;
        let client = HaClient::new(server.uri(), "tok");
        // yes=true; invalid JSON still fails after confirmation check
        let result = fire(&json_out(), &client, "my_event", Some("{invalid}"), true).await;
        assert!(matches!(result, Err(crate::api::HaError::InvalidInput(_))));
    }

    #[tokio::test]
    async fn fire_without_yes_in_non_tty_text_mode_requires_confirmation() {
        use crate::output::OutputFormat;
        // In text mode without --yes and without a TTY, must return ConfirmationRequired.
        let text_out = OutputConfig::new(Some(OutputFormat::Text), false);
        let server = MockServer::start().await;
        let client = HaClient::new(server.uri(), "tok");
        let result = fire(&text_out, &client, "my_event", None, false).await;
        assert!(
            matches!(result, Err(crate::api::HaError::ConfirmationRequired(_))),
            "expected ConfirmationRequired, got {result:?}"
        );
    }
}