hevy-rs 0.1.1

A command-line interface for the Hevy API
use assert_cmd::Command;
use mockito::Server;
use std::fs;
use tempfile::TempDir;

const USER: &str = r#"{"id":"user-123","name":"Ada Lovelace","email":"ada@example.test"}"#;
const USER_RESPONSE: &str =
    r#"{"data":{"id":"user-123","name":"Ada Lovelace","url":"https://hevy.com/user/ada"}}"#;

fn command(server: &Server, config_home: &TempDir) -> Command {
    let mut command = Command::cargo_bin("hevy-rs").unwrap();
    command
        .env_remove("HEVY_API_KEY")
        .env("HEVY_API_BASE_URL", server.url())
        .env("HEVY_CONFIG_DIR", config_home.path())
        .env("XDG_CONFIG_HOME", config_home.path())
        .env("APPDATA", config_home.path())
        .env("LOCALAPPDATA", config_home.path())
        .env("HOME", config_home.path());
    command
}

#[test]
fn help_does_not_expose_the_environment_api_key() {
    let output = Command::cargo_bin("hevy-rs")
        .unwrap()
        .env("HEVY_API_KEY", "very-secret-key")
        .args(["--help"])
        .assert()
        .success()
        .get_output()
        .clone();

    assert!(
        !String::from_utf8(output.stdout)
            .unwrap()
            .contains("very-secret-key")
    );
    assert!(output.stderr.is_empty());
}

#[test]
fn user_get_prefers_explicit_key_and_emits_stable_json() {
    let mut server = Server::new();
    let config_home = TempDir::new().unwrap();
    let request = server
        .mock("GET", "/v1/user/info")
        .match_header("api-key", "explicit-key")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(USER_RESPONSE)
        .create();

    let output = command(&server, &config_home)
        .args([
            "--format",
            "json",
            "--api-key",
            "explicit-key",
            "user",
            "get",
        ])
        .assert()
        .success()
        .get_output()
        .clone();

    assert_eq!(
        serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap(),
        serde_json::json!({
            "id": "user-123",
            "name": "Ada Lovelace",
            "url": "https://hevy.com/user/ada"
        })
    );
    assert!(String::from_utf8(output.stderr).unwrap().is_empty());
    request.assert();
}

#[test]
fn user_get_renders_user_fields_from_the_documented_data_envelope() {
    let mut server = Server::new();
    let config_home = TempDir::new().unwrap();
    let request = server
        .mock("GET", "/v1/user/info")
        .match_header("api-key", "explicit-key")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(USER_RESPONSE)
        .create();

    command(&server, &config_home)
        .args(["--api-key", "explicit-key", "user", "get"])
        .assert()
        .success()
        .stdout("ID: user-123\nName: Ada Lovelace\nURL: https://hevy.com/user/ada\n")
        .stderr("");

    request.assert();
}

#[test]
fn user_get_uses_environment_key_before_per_user_configuration() {
    let mut server = Server::new();
    let config_home = TempDir::new().unwrap();
    let config_dir = config_home.path().join("hevy");
    fs::create_dir_all(&config_dir).unwrap();
    fs::write(config_dir.join("config.toml"), "api_key = \"config-key\"\n").unwrap();
    let request = server
        .mock("GET", "/v1/user/info")
        .match_header("api-key", "environment-key")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(USER)
        .create();

    command(&server, &config_home)
        .env("HEVY_API_KEY", "environment-key")
        .args(["user", "get"])
        .assert()
        .success()
        .stdout("ID: user-123\nName: Ada Lovelace\nEmail: ada@example.test\n")
        .stderr("");

    request.assert();
}

#[test]
fn user_get_uses_per_user_configuration_when_no_higher_precedence_key_exists() {
    let mut server = Server::new();
    let config_home = TempDir::new().unwrap();
    let config_dir = config_home.path().join("hevy");
    fs::create_dir_all(&config_dir).unwrap();
    fs::write(config_dir.join("config.toml"), "api_key = \"config-key\"\n").unwrap();
    let request = server
        .mock("GET", "/v1/user/info")
        .match_header("api-key", "config-key")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(USER)
        .create();

    command(&server, &config_home)
        .args(["user", "get"])
        .assert()
        .success();

    request.assert();
}

#[test]
fn invalid_invocation_has_a_json_error_and_exit_code_two() {
    let server = Server::new();
    let config_home = TempDir::new().unwrap();

    let output = command(&server, &config_home)
        .args(["--format", "json", "user", "unknown"])
        .assert()
        .code(2)
        .get_output()
        .clone();

    let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
    assert_eq!(error["code"], "invocation");
    assert!(error["message"].is_string());
    assert!(output.stdout.is_empty());
}

#[test]
fn missing_key_is_an_authentication_error_without_secret_leakage() {
    let server = Server::new();
    let config_home = TempDir::new().unwrap();

    let output = command(&server, &config_home)
        .args(["--format", "json", "user", "get"])
        .assert()
        .code(3)
        .get_output()
        .clone();

    assert_eq!(
        String::from_utf8(output.stderr).unwrap(),
        "{\"code\":\"authentication\",\"message\":\"No API key was provided. Set HEVY_API_KEY, use --api-key, or configure hevy/config.toml.\"}\n"
    );
    assert!(String::from_utf8(output.stdout).unwrap().is_empty());
}

#[test]
fn api_failures_have_a_stable_json_contract() {
    let mut server = Server::new();
    let config_home = TempDir::new().unwrap();
    let request = server
        .mock("GET", "/v1/user/info")
        .match_header("api-key", "api-key")
        .with_status(404)
        .with_header("x-request-id", "request-123")
        .create();

    let output = command(&server, &config_home)
        .args(["--format", "json", "--api-key", "api-key", "user", "get"])
        .assert()
        .code(4)
        .get_output()
        .clone();

    assert_eq!(
        serde_json::from_slice::<serde_json::Value>(&output.stderr).unwrap(),
        serde_json::json!({
            "code": "api",
            "message": "The Hevy API request failed.",
            "status": 404,
            "request_id": "request-123"
        })
    );
    request.assert();
}

#[test]
fn transient_read_failures_are_retried_and_report_exhaustion_as_transport() {
    let mut server = Server::new();
    let config_home = TempDir::new().unwrap();
    let request = server
        .mock("GET", "/v1/user/info")
        .match_header("api-key", "retry-key")
        .with_status(503)
        .expect(4)
        .create();

    let output = command(&server, &config_home)
        .args(["--format", "json", "--api-key", "retry-key", "user", "get"])
        .assert()
        .code(5)
        .get_output()
        .clone();

    assert_eq!(
        serde_json::from_slice::<serde_json::Value>(&output.stderr).unwrap(),
        serde_json::json!({
            "code": "transport",
            "message": "The Hevy API remained temporarily unavailable while reading.",
            "status": 503
        })
    );
    request.assert();
}

#[test]
fn unauthorized_response_is_categorized_without_echoing_the_key() {
    let mut server = Server::new();
    let config_home = TempDir::new().unwrap();
    let request = server
        .mock("GET", "/v1/user/info")
        .match_header("api-key", "very-secret-key")
        .with_status(401)
        .with_body("very-secret-key")
        .create();

    let output = command(&server, &config_home)
        .args([
            "--format",
            "json",
            "--api-key",
            "very-secret-key",
            "user",
            "get",
        ])
        .assert()
        .code(3)
        .get_output()
        .clone();

    assert_eq!(
        String::from_utf8(output.stderr).unwrap(),
        "{\"code\":\"authentication\",\"message\":\"The Hevy API rejected the supplied API key.\",\"status\":401}\n"
    );
    assert!(
        !String::from_utf8(output.stdout)
            .unwrap()
            .contains("very-secret-key")
    );
    request.assert();
}