deslicer-cli 1.0.0

Deslicer CLI — vendor-neutral CI client for planning, approving, and shipping Splunk changes via DAP.
Documentation
use crate::ci::CiPlatform;
use crate::errors::CliError;
use serde::Serialize;

#[derive(Serialize)]
struct CiOidcRequest<'a> {
    #[serde(skip_serializing_if = "Option::is_none")]
    environment: Option<&'a str>,
}

#[derive(serde::Deserialize)]
struct CiOidcResponse {
    access_token: String,
}

pub async fn exchange(
    observer_api_url: &url::Url,
    jwt: &str,
    platform: CiPlatform,
    environment: Option<&str>,
) -> Result<String, CliError> {
    let url = observer_api_url
        .join("api/v1/auth/ci-oidc")
        .map_err(|e| CliError::Transport(format!("invalid URL join: {e}")))?;

    let body = match environment {
        Some(env) => CiOidcRequest {
            environment: Some(env),
        },
        None => CiOidcRequest { environment: None },
    };

    let http = reqwest::Client::new();
    let response = http
        .post(url)
        .header("Authorization", format!("Bearer {jwt}"))
        .header("X-Deslicer-CI-Platform", platform.header_value())
        .header("Content-Type", "application/json")
        .json(&body)
        .send()
        .await
        .map_err(|e| CliError::Transport(e.to_string()))?;

    let status = response.status();
    let retry_after = parse_retry_after_header(response.headers());
    let response_body = response
        .text()
        .await
        .map_err(|e| CliError::Transport(e.to_string()))?;

    if status.is_success() {
        let parsed: CiOidcResponse = serde_json::from_str(&response_body)
            .map_err(|e| CliError::Transport(format!("invalid ci-oidc JSON: {e}")))?;
        return Ok(parsed.access_token);
    }

    Err(map_exchange_error(status, &response_body, retry_after))
}

fn map_exchange_error(
    status: reqwest::StatusCode,
    body: &str,
    retry_after_secs: Option<u64>,
) -> CliError {
    let message = error_message(body, status);
    match status.as_u16() {
        400 => CliError::UnsupportedPlatform(message),
        401 => CliError::OidcRejected(message),
        403 => {
            if mentions_environment(body) {
                CliError::EnvironmentNotBound(message)
            } else {
                CliError::RepoNotAllowlisted(message)
            }
        }
        409 => CliError::AmbiguousBinding(message),
        429 => CliError::RateLimited {
            retry_after_secs: retry_after_secs.unwrap_or(30),
        },
        500..=599 => CliError::BackendUnavailable(status.to_string()),
        _ => CliError::Other(message),
    }
}

fn error_message(body: &str, status: reqwest::StatusCode) -> String {
    if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
        for key in ["detail", "error", "message"] {
            if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
                if !text.is_empty() {
                    return text.to_string();
                }
            }
        }
    }
    if body.trim().is_empty() {
        format!("HTTP {status}")
    } else {
        body.trim().to_string()
    }
}

fn mentions_environment(text: &str) -> bool {
    text.to_ascii_lowercase().contains("environment")
}

fn parse_retry_after_header(headers: &reqwest::header::HeaderMap) -> Option<u64> {
    headers
        .get(reqwest::header::RETRY_AFTER)
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.trim().parse::<u64>().ok())
}