use qontinui_types::wire::placement::SpawnPlacementResponse;
use reqwest::Client;
use url::Url;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overflow {
Default,
Wrap,
}
impl Overflow {
fn as_query(&self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::Wrap => Some("wrap"),
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SpawnPlacementClientError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("URL build error: {0}")]
Url(#[from] url::ParseError),
#[error("Endpoint returned {status}: {body}")]
Status {
status: reqwest::StatusCode,
body: String,
},
#[error("Response parsing error: {0}")]
Parse(String),
#[error("Endpoint returned envelope without success/data: {error:?}")]
EnvelopeError {
error: Option<String>,
},
}
#[derive(Debug, Clone)]
pub struct SpawnPlacementClient {
base: Url,
http: Client,
}
impl SpawnPlacementClient {
pub fn new(base: Url, http: Client) -> Self {
Self { base, http }
}
pub async fn preview(
&self,
slot: usize,
overflow: Overflow,
) -> Result<SpawnPlacementResponse, SpawnPlacementClientError> {
let mut url = self.base.join("/spawn-placement/preview")?;
{
let mut q = url.query_pairs_mut();
q.append_pair("slot", &slot.to_string());
if let Some(o) = overflow.as_query() {
q.append_pair("overflow", o);
}
}
self.fetch(url).await
}
pub async fn temp(
&self,
index: usize,
overflow: Overflow,
) -> Result<SpawnPlacementResponse, SpawnPlacementClientError> {
let mut url = self.base.join("/spawn-placement/temp")?;
{
let mut q = url.query_pairs_mut();
q.append_pair("index", &index.to_string());
if let Some(o) = overflow.as_query() {
q.append_pair("overflow", o);
}
}
self.fetch(url).await
}
async fn fetch(&self, url: Url) -> Result<SpawnPlacementResponse, SpawnPlacementClientError> {
let resp = self.http.get(url).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(SpawnPlacementClientError::Status { status, body });
}
let value: serde_json::Value = resp.json().await?;
if value.get("data").is_some()
|| value.get("success").is_some()
|| value.get("error").is_some()
{
let success = value
.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let Some(data) = value.get("data") {
if success {
return serde_json::from_value(data.clone())
.map_err(|e| SpawnPlacementClientError::Parse(e.to_string()));
}
}
let error = value
.get("error")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
return Err(SpawnPlacementClientError::EnvelopeError { error });
}
serde_json::from_value(value).map_err(|e| SpawnPlacementClientError::Parse(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use qontinui_types::wire::placement::SpawnPlacementResponse;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sample_response() -> SpawnPlacementResponse {
SpawnPlacementResponse::new(
100,
200,
1280,
720,
"primary".to_string(),
0,
"temp[0]".to_string(),
"temp".to_string(),
)
.with_decorations(Some(false))
}
fn client_for(server: &MockServer) -> SpawnPlacementClient {
let base = Url::parse(&server.uri()).unwrap();
SpawnPlacementClient::new(base, Client::new())
}
#[tokio::test]
async fn temp_parses_wrapped_envelope() {
let server = MockServer::start().await;
let body = serde_json::json!({
"success": true,
"data": sample_response(),
"error": serde_json::Value::Null,
});
Mock::given(method("GET"))
.and(path("/spawn-placement/temp"))
.and(query_param("index", "0"))
.and(query_param("overflow", "wrap"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let placement = client_for(&server)
.temp(0, Overflow::Wrap)
.await
.expect("temp() should succeed for wrapped envelope");
assert_eq!(placement.global_x, 100);
assert_eq!(placement.global_y, 200);
assert_eq!(placement.width, 1280);
assert_eq!(placement.height, 720);
assert_eq!(placement.slot_label, "temp[0]");
assert_eq!(placement.source, "temp");
assert_eq!(placement.decorations, Some(false));
}
#[tokio::test]
async fn temp_parses_bare_payload() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/spawn-placement/temp"))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_response()))
.mount(&server)
.await;
let placement = client_for(&server)
.temp(0, Overflow::Wrap)
.await
.expect("temp() should succeed for bare payload");
assert_eq!(placement.global_x, 100);
assert_eq!(placement.slot_label, "temp[0]");
}
#[tokio::test]
async fn preview_parses_wrapped_envelope() {
let server = MockServer::start().await;
let mut placement = sample_response();
placement.slot_label = "primary".into();
placement.source = "configured".into();
let body = serde_json::json!({
"success": true,
"data": placement.clone(),
});
Mock::given(method("GET"))
.and(path("/spawn-placement/preview"))
.and(query_param("slot", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let got = client_for(&server)
.preview(0, Overflow::Default)
.await
.expect("preview() should succeed");
assert_eq!(got.slot_label, "primary");
assert_eq!(got.source, "configured");
}
#[tokio::test]
async fn wrapped_and_bare_parse_identically() {
let server_w = MockServer::start().await;
let server_b = MockServer::start().await;
let placement = sample_response();
Mock::given(method("GET"))
.and(path("/spawn-placement/temp"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"success": true,
"data": placement.clone(),
})))
.mount(&server_w)
.await;
Mock::given(method("GET"))
.and(path("/spawn-placement/temp"))
.respond_with(ResponseTemplate::new(200).set_body_json(placement.clone()))
.mount(&server_b)
.await;
let wrapped = client_for(&server_w)
.temp(0, Overflow::Wrap)
.await
.expect("wrapped");
let bare = client_for(&server_b)
.temp(0, Overflow::Wrap)
.await
.expect("bare");
assert_eq!(wrapped.global_x, bare.global_x);
assert_eq!(wrapped.global_y, bare.global_y);
assert_eq!(wrapped.width, bare.width);
assert_eq!(wrapped.height, bare.height);
assert_eq!(wrapped.monitor_label, bare.monitor_label);
assert_eq!(wrapped.slot_index, bare.slot_index);
assert_eq!(wrapped.slot_label, bare.slot_label);
assert_eq!(wrapped.source, bare.source);
assert_eq!(wrapped.decorations, bare.decorations);
}
#[tokio::test]
async fn non_2xx_returns_status_error_with_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/spawn-placement/temp"))
.respond_with(
ResponseTemplate::new(404)
.set_body_json(serde_json::json!({"error": "no temp placements configured"})),
)
.mount(&server)
.await;
let err = client_for(&server)
.temp(0, Overflow::Wrap)
.await
.expect_err("temp() should fail on 404");
match err {
SpawnPlacementClientError::Status { status, body } => {
assert_eq!(status.as_u16(), 404);
assert!(
body.contains("no temp placements configured"),
"body: {body}"
);
}
other => panic!("expected Status, got {other:?}"),
}
}
#[tokio::test]
async fn envelope_with_success_false_yields_envelope_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/spawn-placement/temp"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"success": false,
"error": "internal monitor enumeration failed",
})))
.mount(&server)
.await;
let err = client_for(&server)
.temp(0, Overflow::Wrap)
.await
.expect_err("temp() should surface envelope error");
match err {
SpawnPlacementClientError::EnvelopeError { error } => {
assert_eq!(
error.as_deref(),
Some("internal monitor enumeration failed")
);
}
other => panic!("expected EnvelopeError, got {other:?}"),
}
}
#[tokio::test]
async fn overflow_default_omits_query_param() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/spawn-placement/preview"))
.and(query_param("slot", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_response()))
.mount(&server)
.await;
let _ = client_for(&server)
.preview(1, Overflow::Default)
.await
.expect("preview() should match no-overflow request");
}
}