Skip to main content

homeassistant_cli/commands/
event.rs

1use std::io::IsTerminal;
2
3use owo_colors::OwoColorize;
4
5use crate::api::{self, HaClient, HaError};
6use crate::output::{self, OutputConfig};
7
8pub async fn fire(
9    out: &OutputConfig,
10    client: &HaClient,
11    event_type: &str,
12    data: Option<&str>,
13    yes: bool,
14) -> Result<(), HaError> {
15    // Firing events modifies HA state. Without a TTY and without --yes or JSON mode: refuse.
16    let is_tty = std::io::stdin().is_terminal();
17    if !yes && !out.is_json() {
18        if is_tty {
19            eprint!("Fire event '{event_type}'? [y/N] ");
20            use std::io::Write;
21            let _ = std::io::stderr().flush();
22            let mut input = String::new();
23            std::io::stdin()
24                .read_line(&mut input)
25                .map_err(|e| HaError::Other(format!("failed to read stdin: {e}")))?;
26            let answer = input.trim().to_ascii_lowercase();
27            if answer != "y" && answer != "yes" {
28                return Err(HaError::InvalidInput("aborted by user".into()));
29            }
30        } else {
31            // Non-interactive, no --yes: refuse per spec Principle 4.
32            return Err(HaError::ConfirmationRequired(format!(
33                "Firing event '{event_type}' requires confirmation"
34            )));
35        }
36    }
37
38    let body = if let Some(d) = data {
39        Some(
40            serde_json::from_str::<serde_json::Value>(d)
41                .map_err(|e| HaError::InvalidInput(format!("Invalid JSON data: {e}")))?,
42        )
43    } else {
44        None
45    };
46
47    let result = api::events::fire_event(client, event_type, body.as_ref()).await?;
48
49    if out.is_json() {
50        out.print_data(
51            &serde_json::to_string_pretty(&serde_json::json!({"ok": true, "data": result}))
52                .expect("serialize"),
53        );
54    } else {
55        out.print_data(&format!("Fired event: {event_type}"));
56    }
57    Ok(())
58}
59
60pub async fn watch(
61    out: &OutputConfig,
62    client: &HaClient,
63    event_type: Option<&str>,
64) -> Result<(), HaError> {
65    out.print_message(&format!(
66        "Watching events{} (Ctrl+C to stop)...",
67        event_type.map(|t| format!(": {t}")).unwrap_or_default()
68    ));
69
70    api::events::watch_stream(client, event_type, |event| {
71        if out.is_json() {
72            if let Ok(s) =
73                serde_json::to_string_pretty(&serde_json::json!({"ok": true, "data": event}))
74            {
75                println!("{s}");
76            }
77        } else {
78            let time = event
79                .time_fired
80                .as_deref()
81                .map(output::relative_time)
82                .unwrap_or_else(|| "-".to_owned());
83            let data_str = if event.data.is_null() || event.data == serde_json::json!({}) {
84                String::new()
85            } else {
86                format!("  {}", event.data.to_string().dimmed())
87            };
88            println!("{}  {}{}", time.dimmed(), event.event_type.bold(), data_str);
89        }
90        true
91    })
92    .await
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::api::HaClient;
99    use crate::output::{OutputConfig, OutputFormat};
100    use wiremock::matchers::{method, path};
101    use wiremock::{Mock, MockServer, ResponseTemplate};
102
103    fn json_out() -> OutputConfig {
104        OutputConfig::new(Some(OutputFormat::Json), false)
105    }
106
107    #[tokio::test]
108    async fn fire_succeeds_on_200() {
109        let server = MockServer::start().await;
110        Mock::given(method("POST"))
111            .and(path("/api/events/my_event"))
112            .respond_with(
113                ResponseTemplate::new(200)
114                    .set_body_json(serde_json::json!({"message": "Event my_event fired."})),
115            )
116            .mount(&server)
117            .await;
118
119        let client = HaClient::new(server.uri(), "tok");
120        // yes=true to skip confirmation in non-TTY test environment
121        let result = fire(&json_out(), &client, "my_event", None, true).await;
122        assert!(result.is_ok());
123    }
124
125    #[tokio::test]
126    async fn fire_with_invalid_json_returns_error() {
127        let server = MockServer::start().await;
128        let client = HaClient::new(server.uri(), "tok");
129        // yes=true; invalid JSON still fails after confirmation check
130        let result = fire(&json_out(), &client, "my_event", Some("{invalid}"), true).await;
131        assert!(matches!(result, Err(crate::api::HaError::InvalidInput(_))));
132    }
133
134    #[tokio::test]
135    async fn fire_without_yes_in_non_tty_text_mode_requires_confirmation() {
136        use crate::output::OutputFormat;
137        // In text mode without --yes and without a TTY, must return ConfirmationRequired.
138        let text_out = OutputConfig::new(Some(OutputFormat::Text), false);
139        let server = MockServer::start().await;
140        let client = HaClient::new(server.uri(), "tok");
141        let result = fire(&text_out, &client, "my_event", None, false).await;
142        assert!(
143            matches!(result, Err(crate::api::HaError::ConfirmationRequired(_))),
144            "expected ConfirmationRequired, got {result:?}"
145        );
146    }
147}