deskribe 0.1.2

Native Kubernetes resource descriptions using a caller-provided kube client
use std::sync::{Arc, Mutex};

use deskribe::{RenderOptions, gather};
use kube::{
    Client,
    api::{ApiResource, DynamicObject},
    core::GroupVersionKind,
};
use serde_json::json;

fn fixture(
    uid: &str,
    events_status: u16,
) -> (Client, Arc<Mutex<Vec<String>>>, ApiResource, DynamicObject) {
    let requests = Arc::new(Mutex::new(Vec::new()));
    let seen = requests.clone();
    let uid = uid.to_owned();
    let client = Client::new(
        tower::service_fn(move |request: http::Request<kube::client::Body>| {
            assert_eq!(request.method(), http::Method::GET);
            let path = request.uri().path().to_owned();
            seen.lock().unwrap().push(path.clone());
            let (status, body) = if path.ends_with("/configmaps/demo") {
                (
                    200,
                    json!({"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"demo","namespace":"default","uid":uid},"data":{"key":"fresh-value"}}),
                )
            } else {
                assert!(path.ends_with("/events"), "{path}");
                if events_status == 200 {
                    (
                        200,
                        json!({"apiVersion":"v1","kind":"EventList","items":[]}),
                    )
                } else {
                    (
                        events_status,
                        json!({"apiVersion":"v1","kind":"Status","status":"Failure","code":events_status,"reason":"Forbidden","message":"fixture events forbidden"}),
                    )
                }
            };
            async move {
                Ok::<_, std::convert::Infallible>(
                    http::Response::builder()
                        .status(status)
                        .body(kube::client::Body::from(body.to_string().into_bytes()))
                        .unwrap(),
                )
            }
        }),
        "default",
    );
    let resource = ApiResource::from_gvk(&GroupVersionKind::gvk("", "v1", "ConfigMap"));
    let mut selected = DynamicObject::new("demo", &resource);
    selected.metadata.namespace = Some("default".into());
    selected.metadata.uid = Some("selected-uid".into());
    (client, requests, resource, selected)
}

#[tokio::test]
async fn caller_owned_client_gathers_once_and_rendering_is_offline() {
    let (client, requests, resource, selected) = fixture("selected-uid", 200);
    let description = gather(client.clone(), &resource, &selected).await.unwrap();
    drop(client);
    assert_eq!(requests.lock().unwrap().len(), 2);
    assert_eq!(description.object().data["data"]["key"], "fresh-value");
    let options = RenderOptions {
        now: "2026-01-01T00:00:00Z".parse().unwrap(),
        timezone: k8s_openapi::jiff::tz::TimeZone::UTC,
    };
    let first = description.render(&options).unwrap();
    assert_eq!(first, description.render(&options).unwrap());
    assert!(first.contains("fresh-value"));
    assert_eq!(requests.lock().unwrap().len(), 2);
    assert_eq!(
        description.into_object().metadata.uid.as_deref(),
        Some("selected-uid")
    );
}

#[tokio::test]
async fn replacement_is_rejected_before_rendering() {
    let (client, _, resource, selected) = fixture("replacement-uid", 200);
    let error = gather(client, &resource, &selected)
        .await
        .err()
        .expect("changed UID must fail");
    assert!(error.contains("resource was replaced"));
}

#[tokio::test]
async fn required_configmap_events_failure_is_not_silently_ignored() {
    let (client, _, resource, selected) = fixture("selected-uid", 403);
    let error = gather(client, &resource, &selected)
        .await
        .err()
        .expect("required events must fail");
    assert!(error.contains("fixture events forbidden"));
}