homeassistant-cli 0.1.14

Agent-friendly Home Assistant CLI with JSON output, structured exit codes, and schema introspection
Documentation
use crate::api::{HaClient, HaError, ServiceDomain};

pub async fn list_services(client: &HaClient) -> Result<Vec<ServiceDomain>, HaError> {
    let resp = client.get("/api/services").send().await?;
    match resp.status().as_u16() {
        200 => Ok(resp.json().await?),
        401 | 403 => Err(HaError::Auth("Unauthorized".into())),
        status => Err(HaError::Api {
            status,
            message: resp.text().await.unwrap_or_default(),
        }),
    }
}

pub async fn call_service(
    client: &HaClient,
    domain: &str,
    service: &str,
    data: Option<&serde_json::Value>,
) -> Result<serde_json::Value, HaError> {
    let req = client.post(&format!("/api/services/{domain}/{service}"));
    let req = if let Some(d) = data { req.json(d) } else { req };
    let resp = req.send().await?;
    match resp.status().as_u16() {
        200 => Ok(resp.json().await?),
        401 | 403 => Err(HaError::Auth("Unauthorized".into())),
        404 => Err(HaError::NotFound(format!(
            "Service '{domain}.{service}' not found"
        ))),
        status => Err(HaError::Api {
            status,
            message: resp.text().await.unwrap_or_default(),
        }),
    }
}

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

    #[tokio::test]
    async fn list_services_returns_domains() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/api/services"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
                {
                    "domain": "light",
                    "services": {
                        "turn_on": {"name": "Turn on", "description": "Turn on a light"},
                        "turn_off": {"name": "Turn off", "description": "Turn off a light"}
                    }
                }
            ])))
            .mount(&server)
            .await;

        let client = HaClient::new(server.uri(), "tok");
        let domains = list_services(&client).await.unwrap();
        assert_eq!(domains.len(), 1);
        assert_eq!(domains[0].domain, "light");
        assert!(domains[0].services.contains_key("turn_on"));
    }

    #[tokio::test]
    async fn call_service_sends_post_with_data() {
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/api/services/light/turn_on"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
            .mount(&server)
            .await;

        let client = HaClient::new(server.uri(), "tok");
        let result = call_service(
            &client,
            "light",
            "turn_on",
            Some(&serde_json::json!({"entity_id": "light.living_room"})),
        )
        .await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn call_service_returns_not_found_on_404() {
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/api/services/fake/service"))
            .respond_with(ResponseTemplate::new(404))
            .mount(&server)
            .await;

        let client = HaClient::new(server.uri(), "tok");
        let result = call_service(&client, "fake", "service", None).await;
        assert!(matches!(result, Err(crate::api::HaError::NotFound(_))));
    }
}