runtime-cli 0.1.0

Command-line client for managing git projects on Runtime
Documentation
//! Thin GraphQL client. Uses `reqwest::blocking` so the CLI stays
//! synchronous end-to-end — no tokio runtime, no async noise in the
//! subcommand modules.
//!
//! `Client::execute` posts to `<host>/graphql` with optional bearer
//! auth. Errors in the response's `errors` array surface as a single
//! flat error string; the caller doesn't have to know GraphQL's
//! structured-error shape.

use anyhow::{anyhow, bail, Result};
use serde_json::{json, Value};

use crate::config::Config;

pub struct Client {
    inner: reqwest::blocking::Client,
    host: String,
    token: Option<String>,
}

impl Client {
    /// Construct an authenticated client from a saved config.
    pub fn from_config(cfg: &Config) -> Result<Self> {
        Self::new(cfg.host.clone(), Some(cfg.token.clone()))
    }

    pub fn new(host: String, token: Option<String>) -> Result<Self> {
        let inner = reqwest::blocking::Client::builder()
            .user_agent(concat!("runtime-cli/", env!("CARGO_PKG_VERSION")))
            .build()?;
        Ok(Self {
            inner,
            host: host.trim_end_matches('/').to_string(),
            token,
        })
    }

    pub fn host(&self) -> &str {
        &self.host
    }

    /// Run `query` with optional `variables` and return the parsed
    /// `data` slice. GraphQL errors are flattened into one
    /// `anyhow::Error`.
    pub fn execute(&self, query: &str, variables: Value) -> Result<Value> {
        let body = json!({ "query": query, "variables": variables });
        let mut req = self
            .inner
            .post(format!("{}/graphql", self.host))
            .json(&body);
        if let Some(t) = &self.token {
            req = req.bearer_auth(t);
        }
        let resp = req.send()?;
        let status = resp.status();
        let text = resp.text()?;
        let parsed: Value = serde_json::from_str(&text)
            .map_err(|e| anyhow!("non-JSON response (HTTP {status}): {e}: {text}"))?;
        if let Some(errs) = parsed.get("errors").and_then(|v| v.as_array()) {
            if !errs.is_empty() {
                // Issue #31.2 — render each GraphQL error with its
                // `extensions.code` when present so the user sees
                // \"validation failed\" / \"conflict\" / \"not found\"
                // rather than a flat \"GraphQL error\" wrapper. The
                // codes were introduced by #11's `extend_domain`.
                bail!("{}", render_graphql_errors(errs));
            }
        }
        if !status.is_success() {
            bail!("HTTP {status}: {text}");
        }
        Ok(parsed.get("data").cloned().unwrap_or(Value::Null))
    }

    /// Run a query without flattening errors — used by `runtime api`,
    /// which pretty-prints the entire response.
    pub fn execute_raw(&self, query: &str, variables: Value) -> Result<Value> {
        let body = json!({ "query": query, "variables": variables });
        let mut req = self
            .inner
            .post(format!("{}/graphql", self.host))
            .json(&body);
        if let Some(t) = &self.token {
            req = req.bearer_auth(t);
        }
        let resp = req.send()?;
        let text = resp.text()?;
        Ok(serde_json::from_str(&text)?)
    }
}

/// Issue #31.2 — render GraphQL errors with their `extensions.code`
/// when present. Falls back to the legacy flat-join shape when the
/// server doesn't surface codes.
///
/// Examples:
///   Validation: title: must not be empty
///   Conflict: webhook with that URL already exists; Not found: webhook 42
fn render_graphql_errors(errs: &[Value]) -> String {
    let lines: Vec<String> = errs
        .iter()
        .filter_map(|e| {
            let msg = e.get("message").and_then(|m| m.as_str())?;
            let code = e
                .get("extensions")
                .and_then(|x| x.get("code"))
                .and_then(|c| c.as_str());
            Some(match code {
                Some(c) => format!("{}: {msg}", humanize_code(c)),
                None => msg.to_string(),
            })
        })
        .collect();
    if lines.is_empty() {
        return "GraphQL error: (no message)".to_string();
    }
    lines.join("; ")
}

fn humanize_code(code: &str) -> &str {
    match code {
        "NOT_FOUND" => "Not found",
        "CONFLICT" => "Conflict",
        "FORBIDDEN" => "Forbidden",
        "VALIDATION" => "Validation",
        "UNIMPLEMENTED" => "Unimplemented",
        "STORAGE" => "Storage error",
        other => other,
    }
}

/// Load the config and build an authenticated client. Common helper
/// for every authenticated subcommand.
pub fn authenticated() -> Result<(Client, Config)> {
    let cfg = Config::load()?;
    let client = Client::from_config(&cfg)?;
    Ok((client, cfg))
}

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

    #[test]
    fn render_error_with_validation_code() {
        let errs = vec![json!({
            "message": "title: must not be empty",
            "extensions": { "code": "VALIDATION" }
        })];
        assert_eq!(
            render_graphql_errors(&errs),
            "Validation: title: must not be empty"
        );
    }

    #[test]
    fn render_error_without_code_falls_back_to_message() {
        let errs = vec![json!({ "message": "PERMISSION_DENIED" })];
        assert_eq!(render_graphql_errors(&errs), "PERMISSION_DENIED");
    }

    #[test]
    fn render_multiple_errors_semicolon_joined() {
        let errs = vec![
            json!({
                "message": "a",
                "extensions": { "code": "CONFLICT" }
            }),
            json!({
                "message": "b",
                "extensions": { "code": "NOT_FOUND" }
            }),
        ];
        assert_eq!(render_graphql_errors(&errs), "Conflict: a; Not found: b");
    }
}