mecha10-cli 0.7.0

Mecha10 CLI tool
Documentation
//! Latest published build-version lookup
//!
//! Calls `GET {control_plane_url}/api/catalog/binaries/by-robot/{robot_id}/latest?target={target}`,
//! the robot-identity counterpart to `mecha10-launcher-service`'s `BinaryApiClient::get_latest_binary`
//! (which looks up by project `name`), and mirrors `publish_status.rs`'s plain-reqwest shape rather
//! than introducing a `BinaryApiClient`-style abstraction into this crate.
//!
//! Used by `mecha10 init` when linking a new project directory to an already-registered robot:
//! that robot already has real deployed builds, so its freshly scaffolded `mecha10.json` should
//! start from the version that's actually running (the latest `mecha10 build <target>` upload),
//! not the CLI's own template release version, which tracks scaffolding content and has no
//! relationship to what's been built/deployed for this robot.
//!
//! Deliberately keyed by `robot_id`, not by project `name` (an earlier revision of this lookup
//! was) - `name` is just whatever arbitrary local project/folder name a given `mecha10 init`
//! checkout happened to pick, and a new checkout can link to an *already-registered* robot under
//! a different typed name entirely (that's the point of `mecha10 init`'s "link to existing
//! robot" prompt). Looking up by `name` then silently 404s (no visible error, since a 404 here
//! just means "no prior build") whenever the new project's name doesn't happen to match whatever
//! name the robot's builds were actually uploaded under - which is exactly the "it's never
//! worked and I don't see why" failure mode across several earlier fix attempts at this lookup.
//! `robot_id` is the one value every upload carries that's tied to the robot itself.

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

#[derive(Debug, Clone, Deserialize)]
struct LatestBinaryVersion {
    version: String,
}

/// Pure URL-construction logic behind [`fetch_latest_build_version`], split out so it's unit
/// testable without a real HTTP client/server (mirrors `publish_status.rs`'s `build_versions_url`).
fn build_latest_url(control_plane_url: &str, robot_id: &str, target: &str) -> String {
    format!(
        "{}/api/catalog/binaries/by-robot/{}/latest?target={}",
        control_plane_url.trim_end_matches('/'),
        robot_id,
        target
    )
}

/// Look up the latest published build version for `robot_id` and `target` ("robot"/"remote").
///
/// Returns `Ok(None)` when nothing has been built/uploaded for this robot yet (a 404 from the
/// registry - not an error, just "no prior build"). Returns `Err` for anything else (no
/// credentials, network failure, non-2xx/404 response, bad JSON) - callers must treat every error
/// the same way they'd treat `Ok(None)`: fall back to whatever version they'd otherwise use,
/// since this lookup must never become a hard dependency for `mecha10 init`.
pub async fn fetch_latest_build_version(
    control_plane_url: &str,
    robot_id: &str,
    target: &str,
    api_key: &str,
    user_id: &str,
) -> Result<Option<String>> {
    let url = build_latest_url(control_plane_url, robot_id, 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")?;

    if response.status().as_u16() == 404 {
        return Ok(None);
    }

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

    let info: LatestBinaryVersion = response
        .json()
        .await
        .context("Failed to parse latest build version response")?;

    Ok(Some(info.version))
}

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

    #[test]
    fn build_latest_url_includes_target_query_param() {
        let url = build_latest_url("https://api.example.com", "robot-123", "remote");
        assert_eq!(
            url,
            "https://api.example.com/api/catalog/binaries/by-robot/robot-123/latest?target=remote"
        );
    }

    #[test]
    fn build_latest_url_trims_trailing_slash_on_control_plane_url() {
        let url = build_latest_url("https://api.example.com/", "robot-123", "robot");
        assert_eq!(
            url,
            "https://api.example.com/api/catalog/binaries/by-robot/robot-123/latest?target=robot"
        );
    }
}