mecha10-cli 0.7.0

Mecha10 CLI tool
Documentation
//! Registry "is this version published?" check
//!
//! Calls `GET {control_plane_url}/api/catalog/binaries/{name}/versions/{version}?target={target}`,
//! a companion endpoint to the binary registry `upload.rs` already POSTs to, added so `mecha10
//! build`/`mecha10 run remote` can tell whether a version has already been deployed before
//! building/running it again. Uses the same `Authorization: Bearer <api_key>` + `X-User-ID`
//! header convention as `upload.rs`'s `upload_to_registry` and launcher-service's
//! `BinaryApiClient::get_latest_binary`.
//!
//! Deliberately a plain reqwest-based function rather than a new client struct - this crate has
//! no existing `BinaryApiClient`-style abstraction of its own (that lives in
//! `mecha10-launcher-service`, a different binary), so this mirrors `upload.rs`'s existing
//! `upload_to_registry` shape instead of introducing a parallel one.

use anyhow::{Context, Result};
use serde::Deserialize;

#[derive(Debug, Clone, Deserialize)]
pub struct PublishStatus {
    pub exists: bool,
    #[allow(dead_code)]
    pub version: String,
    #[allow(dead_code)]
    pub target: String,
    #[serde(default)]
    pub platforms: Vec<String>,
}

/// Pure URL-construction logic behind [`check_published_version`], split out so it's unit
/// testable without a real HTTP client/server (mirrors `binary_api.rs`'s
/// `build_latest_binary_url`).
fn build_versions_url(control_plane_url: &str, name: &str, version: &str, target: &str) -> String {
    format!(
        "{}/api/catalog/binaries/{}/versions/{}?target={}",
        control_plane_url.trim_end_matches('/'),
        name,
        version,
        target
    )
}

/// Query whether `name`@`version` has already been published for `target` ("robot"/"remote").
///
/// Returns an `Err` whenever the check couldn't be completed for any reason (no credentials
/// configured, network unreachable, non-2xx response, bad JSON, etc.) - callers must treat every
/// error the same way: log a warning and proceed as if the check had never happened, since this
/// must never become a hard dependency for building/running offline or without an API key
/// configured.
pub async fn check_published_version(
    control_plane_url: &str,
    name: &str,
    version: &str,
    target: &str,
    api_key: Option<&str>,
    user_id: Option<&str>,
) -> Result<PublishStatus> {
    let (api_key, user_id) = match (api_key, user_id) {
        (Some(key), Some(id)) if !key.is_empty() => (key, id),
        _ => anyhow::bail!("No credentials configured"),
    };

    let url = build_versions_url(control_plane_url, name, version, target);

    let client = reqwest::Client::new();
    let response = client
        .get(&url)
        .header("Authorization", format!("Bearer {}", api_key))
        .header("X-User-ID", user_id)
        .send()
        .await
        .context("Failed to reach registry")?;

    let status = response.status();
    if !status.is_success() {
        let body = response.text().await.unwrap_or_default();
        anyhow::bail!("Publish status check failed with status {}: {}", status, body);
    }

    response
        .json::<PublishStatus>()
        .await
        .context("Failed to parse publish status response")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_versions_url_includes_target_query_param() {
        let url = build_versions_url("https://api.example.com", "my-robot", "1.2.3", "remote");
        assert_eq!(
            url,
            "https://api.example.com/api/catalog/binaries/my-robot/versions/1.2.3?target=remote"
        );
    }

    #[test]
    fn build_versions_url_strips_trailing_slash_from_base() {
        let url = build_versions_url("https://api.example.com/", "my-robot", "1.2.3", "robot");
        assert!(url.starts_with("https://api.example.com/api/catalog/binaries/"));
        assert!(!url.contains("com//api"));
    }

    #[tokio::test]
    async fn check_published_version_errors_without_credentials() {
        let err = check_published_version("https://api.example.com", "my-robot", "1.2.3", "robot", None, None)
            .await
            .unwrap_err();
        assert!(err.to_string().contains("No credentials configured"));
    }

    #[tokio::test]
    async fn check_published_version_errors_with_empty_api_key() {
        let err = check_published_version(
            "https://api.example.com",
            "my-robot",
            "1.2.3",
            "robot",
            Some(""),
            Some("user-1"),
        )
        .await
        .unwrap_err();
        assert!(err.to_string().contains("No credentials configured"));
    }
}